doccupine 0.0.69 → 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 +104 -1
- package/dist/lib/layout.d.ts +1 -1
- package/dist/lib/layout.js +46 -38
- package/dist/lib/structures.js +6 -4
- package/dist/lib/types.d.ts +8 -0
- package/dist/templates/components/Chat.d.ts +1 -1
- package/dist/templates/components/Chat.js +9 -1
- package/dist/templates/components/PostHogProvider.d.ts +1 -0
- package/dist/templates/components/PostHogProvider.js +72 -0
- package/dist/templates/lib/posthog.d.ts +1 -0
- package/dist/templates/lib/posthog.js +36 -0
- package/dist/templates/mdx/analytics.mdx.d.ts +1 -0
- package/dist/templates/mdx/analytics.mdx.js +112 -0
- package/dist/templates/mdx/deployment-and-hosting.mdx.d.ts +1 -1
- package/dist/templates/mdx/deployment-and-hosting.mdx.js +1 -1
- package/dist/templates/next.config.d.ts +2 -1
- package/dist/templates/next.config.js +33 -1
- package/dist/templates/package.js +2 -0
- package/dist/templates/proxy.d.ts +2 -1
- package/dist/templates/proxy.js +65 -4
- package/package.json +1 -1
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
|
-
|
|
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"));
|
package/dist/lib/layout.d.ts
CHANGED
|
@@ -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 {};
|
package/dist/lib/layout.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
);
|
package/dist/lib/structures.js
CHANGED
|
@@ -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";
|
|
@@ -107,9 +108,7 @@ export const appStructure = {
|
|
|
107
108
|
".prettierrc": prettierrcTemplate,
|
|
108
109
|
".prettierignore": prettierignoreTemplate,
|
|
109
110
|
"eslint.config.mjs": eslintConfigTemplate,
|
|
110
|
-
"next.config.ts": nextConfigTemplate,
|
|
111
111
|
"package.json": packageJsonTemplate,
|
|
112
|
-
"proxy.ts": proxyTemplate,
|
|
113
112
|
"tsconfig.json": tsconfigTemplate,
|
|
114
113
|
"app/not-found.tsx": notFoundTemplate,
|
|
115
114
|
"app/theme.ts": themeTemplate,
|
|
@@ -125,6 +124,7 @@ export const appStructure = {
|
|
|
125
124
|
"services/llm/index.ts": llmIndexTemplate,
|
|
126
125
|
"services/llm/types.ts": llmTypesTemplate,
|
|
127
126
|
"types/styled.d.ts": styledDTemplate,
|
|
127
|
+
"lib/posthog.ts": posthogServerTemplate,
|
|
128
128
|
"utils/branding.ts": brandingTemplate,
|
|
129
129
|
"utils/orderNavItems.ts": orderNavItemsTemplate,
|
|
130
130
|
"utils/rateLimit.ts": rateLimitTemplate,
|
|
@@ -136,6 +136,7 @@ export const appStructure = {
|
|
|
136
136
|
"components/DocsSideBar.tsx": docsSideBarTemplate,
|
|
137
137
|
"components/MDXComponents.tsx": mdxComponentsTemplate,
|
|
138
138
|
"components/SectionNavProvider.tsx": sectionNavProviderTemplate,
|
|
139
|
+
"components/PostHogProvider.tsx": postHogProviderTemplate,
|
|
139
140
|
"components/SideBar.tsx": sideBarTemplate,
|
|
140
141
|
"components/layout/Accordion.tsx": accordionTemplate,
|
|
141
142
|
"components/layout/ActionBar.tsx": actionBarTemplate,
|
|
@@ -167,6 +168,7 @@ export const appStructure = {
|
|
|
167
168
|
export const startingDocsStructure = {
|
|
168
169
|
"accordion.mdx": accordionMdxTemplate,
|
|
169
170
|
"ai-assistant.mdx": aiAssistantMdxTemplate,
|
|
171
|
+
"analytics.mdx": analyticsMdxTemplate,
|
|
170
172
|
"buttons.mdx": buttonsMdxTemplate,
|
|
171
173
|
"callouts.mdx": calloutsMdxTemplate,
|
|
172
174
|
"cards.mdx": cardsMdxTemplate,
|
package/dist/lib/types.d.ts
CHANGED
|
@@ -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
|
|
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
|
+
`;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const posthogServerTemplate = "import { PostHog } from \"posthog-node\";\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\nlet client: PostHog | null = null;\n\nexport function getPostHogServerClient(): PostHog | null {\n if (\n analyticsConfig?.provider !== \"posthog\" ||\n !analyticsConfig.posthog?.key\n ) {\n return null;\n }\n\n if (!client) {\n const host = analyticsConfig.posthog.host || \"https://us.i.posthog.com\";\n\n client = new PostHog(analyticsConfig.posthog.key, {\n host,\n flushAt: 1,\n flushInterval: 0,\n });\n }\n\n return client;\n}\n";
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export const posthogServerTemplate = `import { PostHog } from "posthog-node";
|
|
2
|
+
import rawAnalyticsConfig from "@/analytics.json";
|
|
3
|
+
|
|
4
|
+
interface AnalyticsConfig {
|
|
5
|
+
provider?: string;
|
|
6
|
+
posthog?: {
|
|
7
|
+
key?: string;
|
|
8
|
+
host?: string;
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const analyticsConfig = rawAnalyticsConfig as AnalyticsConfig;
|
|
13
|
+
|
|
14
|
+
let client: PostHog | null = null;
|
|
15
|
+
|
|
16
|
+
export function getPostHogServerClient(): PostHog | null {
|
|
17
|
+
if (
|
|
18
|
+
analyticsConfig?.provider !== "posthog" ||
|
|
19
|
+
!analyticsConfig.posthog?.key
|
|
20
|
+
) {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (!client) {
|
|
25
|
+
const host = analyticsConfig.posthog.host || "https://us.i.posthog.com";
|
|
26
|
+
|
|
27
|
+
client = new PostHog(analyticsConfig.posthog.key, {
|
|
28
|
+
host,
|
|
29
|
+
flushAt: 1,
|
|
30
|
+
flushInterval: 0,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return client;
|
|
35
|
+
}
|
|
36
|
+
`;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const analyticsMdxTemplate = "---\ntitle: \"Analytics\"\ndescription: \"Add PostHog analytics to your documentation site with an analytics.json file for both client-side and server-side tracking.\"\ndate: \"2026-02-24\"\ncategory: \"Configuration\"\ncategoryOrder: 3\norder: 10\n---\n# Analytics\nTrack how users interact with your documentation using PostHog. Doccupine supports both client-side and server-side tracking out of the box, with a privacy-first proxy that routes analytics through your own domain.\n\n## analytics.json\nPlace an `analytics.json` at your project root (the same folder where you execute `npx doccupine`).\n\n```json\n{\n \"provider\": \"posthog\",\n \"posthog\": {\n \"key\": \"phc_your_project_api_key\",\n \"host\": \"https://us.i.posthog.com\"\n }\n}\n```\n\n## Fields\n\n- **provider**: The analytics provider to use. Currently only `\"posthog\"` is supported.\n- **posthog.key**: Your PostHog project API key. You can find this in your PostHog project settings under \"Project API Key\". This is a public identifier - it is safe to commit to version control.\n- **posthog.host**: The PostHog ingestion endpoint. Use `https://us.i.posthog.com` for US Cloud or `https://eu.i.posthog.com` for EU Cloud. If you self-host PostHog, use your instance URL.\n\n<Callout type=\"note\">\n The PostHog project API key is a public identifier used to send events. It is not a secret and is safe to include in your repository.\n</Callout>\n\n## What gets tracked\nWhen `analytics.json` is configured, Doccupine enables two layers of tracking:\n\n### Client-side\n- **Page views**: Captured on every client-side navigation using Next.js router hooks.\n- **Page leave**: Automatically captured when a user navigates away from a page.\n\n### Server-side\n- **Page views**: Captured in middleware on every page request, including the initial server render.\n- **Request metadata**: URL, pathname, host, referrer, and user agent are sent with each event.\n- **Smart filtering**: API routes, internal Next.js routes, and prefetch requests are automatically excluded.\n\n## Privacy proxy\nDoccupine routes all analytics traffic through your documentation domain using Next.js rewrites. Instead of sending data directly to PostHog (which ad blockers may intercept), requests go through `/ingest` on your own domain and are proxied to PostHog.\n\nThis means:\n- No third-party domains appear in network requests.\n- Ad blockers are less likely to interfere with tracking.\n- Your users' browsing data stays within your domain boundary before reaching PostHog.\n\nThe proxy destinations are derived automatically from the `host` field in your configuration.\n\n## Getting a PostHog key\n1. Sign up at [posthog.com](https://posthog.com) (free tier available).\n2. Create a new project.\n3. Go to **Project Settings** and copy the **Project API Key**.\n4. Paste it into your `analytics.json` as the `posthog.key` value.\n\n## Behavior\n- **Placement**: Put `analytics.json` in the project root alongside `config.json` and `theme.json`.\n- **Hot reload**: Changes to `analytics.json` are picked up automatically in watch mode. The layout, middleware, and Next.js config are regenerated.\n- **Graceful degradation**: If `analytics.json` is missing, empty, or has an invalid configuration, no tracking code runs. Your site works exactly the same without it.\n- **Dev server restart**: After adding or removing `analytics.json` for the first time, you may need to restart the Next.js dev server for proxy rewrites to take effect.\n\n<Callout type=\"warning\">\n After adding `analytics.json` for the first time, restart the dev server so the proxy rewrites are picked up by Next.js.\n</Callout>\n\n## Regions\nPostHog offers two cloud regions. Set the `host` field accordingly:\n\n| Region | Host |\n|---|---|\n| US Cloud | `https://us.i.posthog.com` |\n| EU Cloud | `https://eu.i.posthog.com` |\n\nIf you omit the `host` field, it defaults to the US Cloud endpoint.\n\n## Example\n\n### Minimal configuration (US Cloud)\n\n```json\n{\n \"provider\": \"posthog\",\n \"posthog\": {\n \"key\": \"phc_your_project_api_key\"\n }\n}\n```\n\n### EU Cloud\n\n```json\n{\n \"provider\": \"posthog\",\n \"posthog\": {\n \"key\": \"phc_your_project_api_key\",\n \"host\": \"https://eu.i.posthog.com\"\n }\n}\n```\n\n## Tips\n- **Start simple**: Add the config with just your key and verify events appear in your PostHog dashboard before customizing further.\n- **Check your dashboard**: After deploying, visit your PostHog project to confirm page view events are flowing in.\n- **Production only**: Consider adding `analytics.json` only in your production/deployment setup to avoid tracking local development traffic.\n";
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
export const analyticsMdxTemplate = `---
|
|
2
|
+
title: "Analytics"
|
|
3
|
+
description: "Add PostHog analytics to your documentation site with an analytics.json file for both client-side and server-side tracking."
|
|
4
|
+
date: "2026-02-24"
|
|
5
|
+
category: "Configuration"
|
|
6
|
+
categoryOrder: 3
|
|
7
|
+
order: 10
|
|
8
|
+
---
|
|
9
|
+
# Analytics
|
|
10
|
+
Track how users interact with your documentation using PostHog. Doccupine supports both client-side and server-side tracking out of the box, with a privacy-first proxy that routes analytics through your own domain.
|
|
11
|
+
|
|
12
|
+
## analytics.json
|
|
13
|
+
Place an \`analytics.json\` at your project root (the same folder where you execute \`npx doccupine\`).
|
|
14
|
+
|
|
15
|
+
\`\`\`json
|
|
16
|
+
{
|
|
17
|
+
"provider": "posthog",
|
|
18
|
+
"posthog": {
|
|
19
|
+
"key": "phc_your_project_api_key",
|
|
20
|
+
"host": "https://us.i.posthog.com"
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
\`\`\`
|
|
24
|
+
|
|
25
|
+
## Fields
|
|
26
|
+
|
|
27
|
+
- **provider**: The analytics provider to use. Currently only \`"posthog"\` is supported.
|
|
28
|
+
- **posthog.key**: Your PostHog project API key. You can find this in your PostHog project settings under "Project API Key". This is a public identifier - it is safe to commit to version control.
|
|
29
|
+
- **posthog.host**: The PostHog ingestion endpoint. Use \`https://us.i.posthog.com\` for US Cloud or \`https://eu.i.posthog.com\` for EU Cloud. If you self-host PostHog, use your instance URL.
|
|
30
|
+
|
|
31
|
+
<Callout type="note">
|
|
32
|
+
The PostHog project API key is a public identifier used to send events. It is not a secret and is safe to include in your repository.
|
|
33
|
+
</Callout>
|
|
34
|
+
|
|
35
|
+
## What gets tracked
|
|
36
|
+
When \`analytics.json\` is configured, Doccupine enables two layers of tracking:
|
|
37
|
+
|
|
38
|
+
### Client-side
|
|
39
|
+
- **Page views**: Captured on every client-side navigation using Next.js router hooks.
|
|
40
|
+
- **Page leave**: Automatically captured when a user navigates away from a page.
|
|
41
|
+
|
|
42
|
+
### Server-side
|
|
43
|
+
- **Page views**: Captured in middleware on every page request, including the initial server render.
|
|
44
|
+
- **Request metadata**: URL, pathname, host, referrer, and user agent are sent with each event.
|
|
45
|
+
- **Smart filtering**: API routes, internal Next.js routes, and prefetch requests are automatically excluded.
|
|
46
|
+
|
|
47
|
+
## Privacy proxy
|
|
48
|
+
Doccupine routes all analytics traffic through your documentation domain using Next.js rewrites. Instead of sending data directly to PostHog (which ad blockers may intercept), requests go through \`/ingest\` on your own domain and are proxied to PostHog.
|
|
49
|
+
|
|
50
|
+
This means:
|
|
51
|
+
- No third-party domains appear in network requests.
|
|
52
|
+
- Ad blockers are less likely to interfere with tracking.
|
|
53
|
+
- Your users' browsing data stays within your domain boundary before reaching PostHog.
|
|
54
|
+
|
|
55
|
+
The proxy destinations are derived automatically from the \`host\` field in your configuration.
|
|
56
|
+
|
|
57
|
+
## Getting a PostHog key
|
|
58
|
+
1. Sign up at [posthog.com](https://posthog.com) (free tier available).
|
|
59
|
+
2. Create a new project.
|
|
60
|
+
3. Go to **Project Settings** and copy the **Project API Key**.
|
|
61
|
+
4. Paste it into your \`analytics.json\` as the \`posthog.key\` value.
|
|
62
|
+
|
|
63
|
+
## Behavior
|
|
64
|
+
- **Placement**: Put \`analytics.json\` in the project root alongside \`config.json\` and \`theme.json\`.
|
|
65
|
+
- **Hot reload**: Changes to \`analytics.json\` are picked up automatically in watch mode. The layout, middleware, and Next.js config are regenerated.
|
|
66
|
+
- **Graceful degradation**: If \`analytics.json\` is missing, empty, or has an invalid configuration, no tracking code runs. Your site works exactly the same without it.
|
|
67
|
+
- **Dev server restart**: After adding or removing \`analytics.json\` for the first time, you may need to restart the Next.js dev server for proxy rewrites to take effect.
|
|
68
|
+
|
|
69
|
+
<Callout type="warning">
|
|
70
|
+
After adding \`analytics.json\` for the first time, restart the dev server so the proxy rewrites are picked up by Next.js.
|
|
71
|
+
</Callout>
|
|
72
|
+
|
|
73
|
+
## Regions
|
|
74
|
+
PostHog offers two cloud regions. Set the \`host\` field accordingly:
|
|
75
|
+
|
|
76
|
+
| Region | Host |
|
|
77
|
+
|---|---|
|
|
78
|
+
| US Cloud | \`https://us.i.posthog.com\` |
|
|
79
|
+
| EU Cloud | \`https://eu.i.posthog.com\` |
|
|
80
|
+
|
|
81
|
+
If you omit the \`host\` field, it defaults to the US Cloud endpoint.
|
|
82
|
+
|
|
83
|
+
## Example
|
|
84
|
+
|
|
85
|
+
### Minimal configuration (US Cloud)
|
|
86
|
+
|
|
87
|
+
\`\`\`json
|
|
88
|
+
{
|
|
89
|
+
"provider": "posthog",
|
|
90
|
+
"posthog": {
|
|
91
|
+
"key": "phc_your_project_api_key"
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
\`\`\`
|
|
95
|
+
|
|
96
|
+
### EU Cloud
|
|
97
|
+
|
|
98
|
+
\`\`\`json
|
|
99
|
+
{
|
|
100
|
+
"provider": "posthog",
|
|
101
|
+
"posthog": {
|
|
102
|
+
"key": "phc_your_project_api_key",
|
|
103
|
+
"host": "https://eu.i.posthog.com"
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
\`\`\`
|
|
107
|
+
|
|
108
|
+
## Tips
|
|
109
|
+
- **Start simple**: Add the config with just your key and verify events appear in your PostHog dashboard before customizing further.
|
|
110
|
+
- **Check your dashboard**: After deploying, visit your PostHog project to confirm page view events are flowing in.
|
|
111
|
+
- **Production only**: Consider adding \`analytics.json\` only in your production/deployment setup to avoid tracking local development traffic.
|
|
112
|
+
`;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const deploymentAndHostingMdxTemplate = "---\ntitle: \"Deployment & Hosting\"\ndescription: \"Deploy your documentation site with the Doccupine Platform or self-host on any platform that supports Next.js.\"\ndate: \"2026-02-19\"\ncategory: \"Configuration\"\ncategoryOrder: 3\norder:
|
|
1
|
+
export declare const deploymentAndHostingMdxTemplate = "---\ntitle: \"Deployment & Hosting\"\ndescription: \"Deploy your documentation site with the Doccupine Platform or self-host on any platform that supports Next.js.\"\ndate: \"2026-02-19\"\ncategory: \"Configuration\"\ncategoryOrder: 3\norder: 11\n---\n# Deployment & Hosting\nThe fastest way to deploy your documentation is with the [Doccupine Platform](https://www.doccupine.com). If you prefer to manage your own infrastructure, you can self-host the generated Next.js app on any platform.\n\n## Doccupine Platform\n\nSign up at [doccupine.com](https://www.doccupine.com) and connect your repository. Your documentation site is live in minutes - no build configuration, no infrastructure to manage.\n\n<Callout type=\"success\">\n The Doccupine Platform is the recommended way to deploy. It handles builds, hosting, SSL, and updates automatically so you can focus on writing documentation.\n</Callout>\n\n### What you get\n- **Automatic deployments** on every push to your repository\n- **Site customization** through a visual dashboard - no code changes needed\n- **Team collaboration** so your whole team can manage docs together\n- **Custom domains** with automatic SSL\n- **AI Assistant and MCP server** included out of the box, no API key required\n- **Zero maintenance** - no servers, no build pipelines, no dependency updates\n\n### Getting started\n1. Create an account at [doccupine.com](https://www.doccupine.com).\n2. Connect your GitHub repository.\n3. Your site is deployed automatically.\n\nEvery push to your repository triggers a new deployment. You can customize your site's appearance, domain, and settings from the dashboard. See the [Platform Overview](/platform) for a full walkthrough of the dashboard, editor, and configuration options.\n\n---\n\n## Self-hosting\n\nDoccupine generates a standard Next.js app, so you can deploy it anywhere that supports Node.js or Next.js.\n\n<Callout type=\"warning\">\n Deploy the generated website directory (the Next.js app), not your MDX source folder. In a monorepo, set the root directory to the generated site folder.\n</Callout>\n\n<Callout type=\"note\">\n Self-hosting requires you to manage your own build pipeline, hosting, SSL certificates, and AI provider API keys. For a hands-off experience, consider the [Doccupine Platform](https://www.doccupine.com).\n</Callout>\n\n### Popular hosting options\n\n- **Vercel** - native Next.js support, zero-config deploys. Connect your repo and set the root directory to the generated app folder.\n- **Netlify** - supports Next.js via the `@netlify/plugin-nextjs` adapter. Works with the standard `next build` output.\n- **AWS Amplify** - fully managed hosting with CI/CD. Supports Next.js SSR out of the box.\n- **Cloudflare Pages** - deploy using the `@cloudflare/next-on-pages` adapter for edge-based hosting.\n- **Docker** - build a container from the generated app using the standard [Next.js Docker example](https://github.com/vercel/next.js/tree/canary/examples/with-docker) and deploy to any container platform.\n- **Node.js server** - run `next build && next start` on any server or VPS with Node.js installed.\n\n### Troubleshooting\n- **Build failed** - check build logs. Ensure your lockfile and correct Node.js version are present.\n- **Missing content** - verify your MDX files and assets are in the repository.\n- **SSR issues on edge platforms** - some features (like the AI chat API routes) require a Node.js runtime. Check your platform's documentation for SSR/API route support.";
|
|
@@ -4,7 +4,7 @@ description: "Deploy your documentation site with the Doccupine Platform or self
|
|
|
4
4
|
date: "2026-02-19"
|
|
5
5
|
category: "Configuration"
|
|
6
6
|
categoryOrder: 3
|
|
7
|
-
order:
|
|
7
|
+
order: 11
|
|
8
8
|
---
|
|
9
9
|
# Deployment & Hosting
|
|
10
10
|
The fastest way to deploy your documentation is with the [Doccupine Platform](https://www.doccupine.com). If you prefer to manage your own infrastructure, you can self-host the generated Next.js app on any platform.
|
|
@@ -1 +1,2 @@
|
|
|
1
|
-
|
|
1
|
+
import type { AnalyticsConfig } from "../lib/types.js";
|
|
2
|
+
export declare const nextConfigTemplate: (analyticsConfig?: AnalyticsConfig | null) => string;
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
export const nextConfigTemplate =
|
|
1
|
+
export const nextConfigTemplate = (analyticsConfig = null) => {
|
|
2
|
+
const hasPostHog = analyticsConfig?.provider === "posthog" && !!analyticsConfig.posthog?.key;
|
|
3
|
+
if (!hasPostHog) {
|
|
4
|
+
return `import type { NextConfig } from "next";
|
|
2
5
|
|
|
3
6
|
const nextConfig: NextConfig = {
|
|
4
7
|
compiler: {
|
|
@@ -8,3 +11,32 @@ const nextConfig: NextConfig = {
|
|
|
8
11
|
|
|
9
12
|
export default nextConfig;
|
|
10
13
|
`;
|
|
14
|
+
}
|
|
15
|
+
const host = analyticsConfig.posthog.host || "https://us.i.posthog.com";
|
|
16
|
+
// Derive the assets host from the API host (e.g. https://us.i.posthog.com -> https://us-assets.i.posthog.com)
|
|
17
|
+
const assetsHost = host
|
|
18
|
+
.replace("://", "://" + "")
|
|
19
|
+
.replace(".i.", "-assets.i.");
|
|
20
|
+
return `import type { NextConfig } from "next";
|
|
21
|
+
|
|
22
|
+
const nextConfig: NextConfig = {
|
|
23
|
+
compiler: {
|
|
24
|
+
styledComponents: true,
|
|
25
|
+
},
|
|
26
|
+
async rewrites() {
|
|
27
|
+
return [
|
|
28
|
+
{
|
|
29
|
+
source: "/ingest/static/:path*",
|
|
30
|
+
destination: "${assetsHost}/static/:path*",
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
source: "/ingest/:path*",
|
|
34
|
+
destination: "${host}/:path*",
|
|
35
|
+
},
|
|
36
|
+
];
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export default nextConfig;
|
|
41
|
+
`;
|
|
42
|
+
};
|
|
@@ -22,6 +22,8 @@ export const packageJsonTemplate = JSON.stringify({
|
|
|
22
22
|
next: "16.1.6",
|
|
23
23
|
"next-mdx-remote": "^6.0.0",
|
|
24
24
|
polished: "^4.3.1",
|
|
25
|
+
"posthog-js": "^1.353.0",
|
|
26
|
+
"posthog-node": "^5.25.0",
|
|
25
27
|
react: "19.2.4",
|
|
26
28
|
"react-dom": "19.2.4",
|
|
27
29
|
"rehype-highlight": "^7.0.2",
|
|
@@ -1 +1,2 @@
|
|
|
1
|
-
|
|
1
|
+
import type { AnalyticsConfig } from "../lib/types.js";
|
|
2
|
+
export declare const proxyTemplate: (analyticsConfig?: AnalyticsConfig | null) => string;
|
package/dist/templates/proxy.js
CHANGED
|
@@ -1,8 +1,68 @@
|
|
|
1
|
-
export const proxyTemplate =
|
|
2
|
-
|
|
1
|
+
export const proxyTemplate = (analyticsConfig = null) => {
|
|
2
|
+
const hasPostHog = analyticsConfig?.provider === "posthog" && !!analyticsConfig.posthog?.key;
|
|
3
|
+
const posthogImport = hasPostHog
|
|
4
|
+
? `import { getPostHogServerClient } from "@/lib/posthog";\n`
|
|
5
|
+
: "";
|
|
6
|
+
const posthogPageviewFn = hasPostHog
|
|
7
|
+
? `
|
|
8
|
+
const SKIP_PAGEVIEW_PATTERN = /^\\/(api|ingest|_next)\\//;
|
|
3
9
|
|
|
4
|
-
|
|
5
|
-
|
|
10
|
+
function captureServerPageview(req: NextRequest, event: NextFetchEvent) {
|
|
11
|
+
const pathname = req.nextUrl.pathname;
|
|
12
|
+
|
|
13
|
+
if (SKIP_PAGEVIEW_PATTERN.test(pathname)) return;
|
|
14
|
+
|
|
15
|
+
const isPrefetch =
|
|
16
|
+
req.headers.get("next-router-prefetch") === "1" ||
|
|
17
|
+
req.headers.get("purpose") === "prefetch";
|
|
18
|
+
if (isPrefetch) return;
|
|
19
|
+
|
|
20
|
+
const posthog = getPostHogServerClient();
|
|
21
|
+
if (!posthog) return;
|
|
22
|
+
|
|
23
|
+
const phCookie = req.cookies.get(
|
|
24
|
+
"ph_${analyticsConfig.posthog.key}_posthog",
|
|
25
|
+
)?.value;
|
|
26
|
+
let distinctId: string | undefined;
|
|
27
|
+
if (phCookie) {
|
|
28
|
+
try {
|
|
29
|
+
distinctId = JSON.parse(phCookie).distinct_id;
|
|
30
|
+
} catch {
|
|
31
|
+
// ignore malformed cookie
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (!distinctId) {
|
|
35
|
+
distinctId = crypto.randomUUID();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
posthog.capture({
|
|
39
|
+
distinctId,
|
|
40
|
+
event: "$pageview",
|
|
41
|
+
properties: {
|
|
42
|
+
$current_url: req.url,
|
|
43
|
+
$pathname: pathname,
|
|
44
|
+
$host: req.headers.get("host") ?? undefined,
|
|
45
|
+
$referrer: req.headers.get("referer") ?? undefined,
|
|
46
|
+
$user_agent: req.headers.get("user-agent") ?? undefined,
|
|
47
|
+
_server_side: true,
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
event.waitUntil(posthog.flush());
|
|
51
|
+
}
|
|
52
|
+
`
|
|
53
|
+
: "";
|
|
54
|
+
const posthogCall = hasPostHog
|
|
55
|
+
? ` captureServerPageview(req, event);\n`
|
|
56
|
+
: "";
|
|
57
|
+
const fnSignature = hasPostHog
|
|
58
|
+
? `export function proxy(req: NextRequest, event: NextFetchEvent)`
|
|
59
|
+
: `export function proxy(req: NextRequest)`;
|
|
60
|
+
const eventImport = hasPostHog ? ", NextFetchEvent" : "";
|
|
61
|
+
return `import { NextResponse } from "next/server";
|
|
62
|
+
import type { NextRequest${eventImport} } from "next/server";
|
|
63
|
+
${posthogImport}${posthogPageviewFn}
|
|
64
|
+
${fnSignature} {
|
|
65
|
+
${posthogCall} // API key auth for /api/mcp when DOCS_API_KEY is configured
|
|
6
66
|
if (req.nextUrl.pathname.startsWith("/api/mcp")) {
|
|
7
67
|
const apiKey = process.env.DOCS_API_KEY;
|
|
8
68
|
if (apiKey) {
|
|
@@ -42,3 +102,4 @@ export const config = {
|
|
|
42
102
|
matcher: ["/:path*"],
|
|
43
103
|
};
|
|
44
104
|
`;
|
|
105
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "doccupine",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.70",
|
|
4
4
|
"description": "Free and open-source documentation platform. Write MDX, get a production-ready site with AI chat, built-in components, and an MCP server - in one command.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|