jamdesk 1.0.13 → 1.0.14

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.
Files changed (132) hide show
  1. package/README.md +89 -4
  2. package/dist/__tests__/unit/auth.test.d.ts +2 -0
  3. package/dist/__tests__/unit/auth.test.d.ts.map +1 -0
  4. package/dist/__tests__/unit/auth.test.js +169 -0
  5. package/dist/__tests__/unit/auth.test.js.map +1 -0
  6. package/dist/__tests__/unit/config.test.d.ts +2 -0
  7. package/dist/__tests__/unit/config.test.d.ts.map +1 -0
  8. package/dist/__tests__/unit/config.test.js +76 -0
  9. package/dist/__tests__/unit/config.test.js.map +1 -0
  10. package/dist/__tests__/unit/deploy.test.d.ts +2 -0
  11. package/dist/__tests__/unit/deploy.test.d.ts.map +1 -0
  12. package/dist/__tests__/unit/deploy.test.js +273 -0
  13. package/dist/__tests__/unit/deploy.test.js.map +1 -0
  14. package/dist/__tests__/unit/deps-sync.test.js +3 -1
  15. package/dist/__tests__/unit/deps-sync.test.js.map +1 -1
  16. package/dist/__tests__/unit/dev-loading-server.test.d.ts +2 -0
  17. package/dist/__tests__/unit/dev-loading-server.test.d.ts.map +1 -0
  18. package/dist/__tests__/unit/dev-loading-server.test.js +141 -0
  19. package/dist/__tests__/unit/dev-loading-server.test.js.map +1 -0
  20. package/dist/__tests__/unit/docs-json-writer.test.d.ts +2 -0
  21. package/dist/__tests__/unit/docs-json-writer.test.d.ts.map +1 -0
  22. package/dist/__tests__/unit/docs-json-writer.test.js +71 -0
  23. package/dist/__tests__/unit/docs-json-writer.test.js.map +1 -0
  24. package/dist/__tests__/unit/loading-page.test.d.ts +2 -0
  25. package/dist/__tests__/unit/loading-page.test.d.ts.map +1 -0
  26. package/dist/__tests__/unit/loading-page.test.js +73 -0
  27. package/dist/__tests__/unit/loading-page.test.js.map +1 -0
  28. package/dist/__tests__/unit/login.test.d.ts +2 -0
  29. package/dist/__tests__/unit/login.test.d.ts.map +1 -0
  30. package/dist/__tests__/unit/login.test.js +100 -0
  31. package/dist/__tests__/unit/login.test.js.map +1 -0
  32. package/dist/__tests__/unit/logout.test.d.ts +2 -0
  33. package/dist/__tests__/unit/logout.test.d.ts.map +1 -0
  34. package/dist/__tests__/unit/logout.test.js +39 -0
  35. package/dist/__tests__/unit/logout.test.js.map +1 -0
  36. package/dist/__tests__/unit/tarball.test.d.ts +2 -0
  37. package/dist/__tests__/unit/tarball.test.d.ts.map +1 -0
  38. package/dist/__tests__/unit/tarball.test.js +126 -0
  39. package/dist/__tests__/unit/tarball.test.js.map +1 -0
  40. package/dist/__tests__/unit/whoami.test.d.ts +2 -0
  41. package/dist/__tests__/unit/whoami.test.d.ts.map +1 -0
  42. package/dist/__tests__/unit/whoami.test.js +47 -0
  43. package/dist/__tests__/unit/whoami.test.js.map +1 -0
  44. package/dist/commands/deploy.d.ts +13 -0
  45. package/dist/commands/deploy.d.ts.map +1 -0
  46. package/dist/commands/deploy.js +265 -0
  47. package/dist/commands/deploy.js.map +1 -0
  48. package/dist/commands/dev.d.ts.map +1 -1
  49. package/dist/commands/dev.js +48 -25
  50. package/dist/commands/dev.js.map +1 -1
  51. package/dist/commands/login.d.ts +8 -0
  52. package/dist/commands/login.d.ts.map +1 -0
  53. package/dist/commands/login.js +135 -0
  54. package/dist/commands/login.js.map +1 -0
  55. package/dist/commands/logout.d.ts +5 -0
  56. package/dist/commands/logout.d.ts.map +1 -0
  57. package/dist/commands/logout.js +17 -0
  58. package/dist/commands/logout.js.map +1 -0
  59. package/dist/commands/whoami.d.ts +5 -0
  60. package/dist/commands/whoami.d.ts.map +1 -0
  61. package/dist/commands/whoami.js +24 -0
  62. package/dist/commands/whoami.js.map +1 -0
  63. package/dist/index.js +50 -7
  64. package/dist/index.js.map +1 -1
  65. package/dist/lib/auth.d.ts +34 -0
  66. package/dist/lib/auth.d.ts.map +1 -0
  67. package/dist/lib/auth.js +105 -0
  68. package/dist/lib/auth.js.map +1 -0
  69. package/dist/lib/config.d.ts +9 -0
  70. package/dist/lib/config.d.ts.map +1 -1
  71. package/dist/lib/config.js +7 -1
  72. package/dist/lib/config.js.map +1 -1
  73. package/dist/lib/dev-loading-server.d.ts +22 -0
  74. package/dist/lib/dev-loading-server.d.ts.map +1 -0
  75. package/dist/lib/dev-loading-server.js +117 -0
  76. package/dist/lib/dev-loading-server.js.map +1 -0
  77. package/dist/lib/docs-config.d.ts +1 -0
  78. package/dist/lib/docs-config.d.ts.map +1 -1
  79. package/dist/lib/docs-config.js.map +1 -1
  80. package/dist/lib/docs-json-writer.d.ts +2 -0
  81. package/dist/lib/docs-json-writer.d.ts.map +1 -0
  82. package/dist/lib/docs-json-writer.js +35 -0
  83. package/dist/lib/docs-json-writer.js.map +1 -0
  84. package/dist/lib/loading-page.d.ts +11 -0
  85. package/dist/lib/loading-page.d.ts.map +1 -0
  86. package/dist/lib/loading-page.js +222 -0
  87. package/dist/lib/loading-page.js.map +1 -0
  88. package/dist/lib/output.d.ts +13 -5
  89. package/dist/lib/output.d.ts.map +1 -1
  90. package/dist/lib/output.js +22 -5
  91. package/dist/lib/output.js.map +1 -1
  92. package/dist/lib/tarball.d.ts +28 -0
  93. package/dist/lib/tarball.d.ts.map +1 -0
  94. package/dist/lib/tarball.js +117 -0
  95. package/dist/lib/tarball.js.map +1 -0
  96. package/package.json +5 -2
  97. package/vendored/app/[[...slug]]/page.tsx +6 -20
  98. package/vendored/app/api/chat/[project]/route.ts +323 -0
  99. package/vendored/app/api/mcp/[project]/route.ts +2 -63
  100. package/vendored/components/chat/ChatCodeBlock.tsx +63 -0
  101. package/vendored/components/chat/ChatEmptyState.tsx +79 -0
  102. package/vendored/components/chat/ChatFAB.tsx +36 -0
  103. package/vendored/components/chat/ChatInput.tsx +106 -0
  104. package/vendored/components/chat/ChatMessage.tsx +176 -0
  105. package/vendored/components/chat/ChatPanel.tsx +206 -0
  106. package/vendored/components/chat/ChatResizeHandle.tsx +108 -0
  107. package/vendored/components/chat/LazyChatPanel.tsx +19 -0
  108. package/vendored/components/layout/LayoutWrapper.tsx +134 -44
  109. package/vendored/components/layout/PageColumns.tsx +40 -0
  110. package/vendored/components/navigation/Header.tsx +74 -29
  111. package/vendored/components/navigation/Sidebar.tsx +17 -2
  112. package/vendored/hooks/useChat.ts +335 -0
  113. package/vendored/hooks/useChatPanel.tsx +101 -0
  114. package/vendored/lib/anthropic-client.ts +19 -0
  115. package/vendored/lib/build/extract-tarball.ts +150 -0
  116. package/vendored/lib/chat-prompt.ts +56 -0
  117. package/vendored/lib/docs-types.ts +14 -0
  118. package/vendored/lib/docs.ts +22 -4
  119. package/vendored/lib/embedding-chunker.ts +173 -0
  120. package/vendored/lib/generate-starter-questions.ts +98 -0
  121. package/vendored/lib/isr-build-executor.ts +2 -1
  122. package/vendored/lib/middleware-helpers.ts +21 -0
  123. package/vendored/lib/route-helpers.ts +96 -0
  124. package/vendored/lib/snippet-loader-isr.ts +107 -1
  125. package/vendored/lib/static-artifacts.ts +3 -2
  126. package/vendored/lib/validate-config.ts +1 -0
  127. package/vendored/lib/vector-store.ts +213 -0
  128. package/vendored/schema/docs-schema.json +33 -0
  129. package/vendored/scripts/dev-project.cjs +6 -0
  130. package/vendored/shared/types.ts +6 -5
  131. package/vendored/tailwind.config.ts +9 -0
  132. package/vendored/themes/jam/variables.css +2 -2
@@ -85,13 +85,29 @@ export function getImagesDir(): string {
85
85
  return path.join(process.cwd(), 'content', 'images');
86
86
  }
87
87
 
88
- // Cache the config to avoid re-parsing 368KB+ JSON for every page during SSR
88
+ // Cache the config to avoid re-parsing 368KB+ JSON for every page during SSR.
89
+ // In development, reads from the project source (not public/docs.json) and uses
90
+ // mtime-based invalidation so docs.json edits are picked up on browser refresh
91
+ // without writing to public/ (which triggers Turbopack recompilation hangs).
89
92
  let cachedConfig: DocsConfig | null = null;
93
+ let cachedMtimeMs = 0;
94
+ let deprecationWarningsLogged = false;
90
95
 
91
96
  export function getDocsConfig(): DocsConfig {
92
- if (cachedConfig) return cachedConfig;
97
+ // In dev, read from project source so we pick up edits without touching public/
98
+ const isDev = process.env.NODE_ENV === 'development';
99
+ const docsPath = isDev ? getProjectDocsConfigPath() : getDocsConfigPath();
100
+
101
+ if (cachedConfig) {
102
+ if (isDev) {
103
+ // Check mtime to detect file changes (cheap stat call vs 368KB readFileSync)
104
+ const mtimeMs = fs.statSync(docsPath).mtimeMs;
105
+ if (mtimeMs === cachedMtimeMs) return cachedConfig;
106
+ } else {
107
+ return cachedConfig;
108
+ }
109
+ }
93
110
 
94
- const docsPath = getDocsConfigPath();
95
111
  const fileContents = fs.readFileSync(docsPath, 'utf8');
96
112
  const rawConfig = JSON.parse(fileContents);
97
113
 
@@ -99,13 +115,15 @@ export function getDocsConfig(): DocsConfig {
99
115
  const { config, warnings } = normalizeConfig(rawConfig);
100
116
 
101
117
  // Log deprecation warnings (only once due to caching)
102
- if (warnings.length > 0) {
118
+ if (warnings.length > 0 && !deprecationWarningsLogged) {
119
+ deprecationWarningsLogged = true;
103
120
  console.warn('\n⚠️ Deprecated docs.json fields detected:');
104
121
  warnings.forEach((w) => console.warn(` - ${w}`));
105
122
  console.warn('');
106
123
  }
107
124
 
108
125
  cachedConfig = config;
126
+ cachedMtimeMs = fs.statSync(docsPath).mtimeMs;
109
127
  return cachedConfig;
110
128
  }
111
129
 
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Embedding Chunker
3
+ *
4
+ * Splits MDX page content into chunks suitable for embedding generation.
5
+ * Each chunk is ~2000 chars (~500 tokens), split at section boundaries
6
+ * first, then at sentence boundaries for oversized sections.
7
+ */
8
+ import { extractSections } from './static-artifacts.js';
9
+
10
+ export interface EmbeddingChunk {
11
+ /** Unique ID: `${pageSlug}#${sectionIndex}` */
12
+ id: string;
13
+ /** Page slug derived from file path (without extension) */
14
+ pageSlug: string;
15
+ /** Heading of the section this chunk belongs to */
16
+ sectionHeading: string;
17
+ /** Plain text content, stripped of markdown and JSX */
18
+ content: string;
19
+ /** Page title from frontmatter, or slug-derived fallback */
20
+ pageTitle: string;
21
+ }
22
+
23
+ /**
24
+ * Light markdown cleanup for embedding content.
25
+ * Keeps code blocks and inline code (critical for docs Q&A) but strips
26
+ * formatting noise (bold/italic markers, JSX tags, image syntax, frontmatter).
27
+ *
28
+ * Code blocks are protected from tag stripping to preserve JSX/HTML inside code.
29
+ * MDX component TAGS are stripped but their content is kept — so
30
+ * `<CodeGroup>```js\ncode\n```</CodeGroup>` preserves the code block.
31
+ */
32
+ function stripForEmbedding(text: string): string {
33
+ // Protect fenced code blocks and inline code from tag stripping.
34
+ // Code can contain <Component> or <tag> syntax that should NOT be stripped.
35
+ const preserved: string[] = [];
36
+ let safe = text
37
+ .replace(/```[\s\S]*?```/g, (m) => {
38
+ preserved.push(m.replace(/```[^\n]*\n/g, '```\n')); // strip language hints + titles
39
+ return `\x00${preserved.length - 1}\x00`;
40
+ })
41
+ .replace(/`[^`\n]+`/g, (m) => {
42
+ preserved.push(m);
43
+ return `\x00${preserved.length - 1}\x00`;
44
+ });
45
+
46
+ let result = safe
47
+ .replace(/^---\n[\s\S]*?\n---\n?/, '') // YAML frontmatter
48
+ .replace(/^import\s+.*$/gm, '') // MDX import statements
49
+ .replace(/#{1,6}\s/g, '') // heading markers
50
+ .replace(/\*\*([^*]+)\*\*/g, '$1') // bold
51
+ .replace(/\*([^*]+)\*/g, '$1') // italic
52
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // links → text only
53
+ .replace(/!\[([^\]]*)\]\([^)]+\)/g, '') // images
54
+ .replace(/<[^>]+>/g, '') // ALL tags (MDX + HTML) — content kept
55
+ .replace(/\n{3,}/g, '\n\n'); // collapse excessive newlines
56
+
57
+ // Restore protected code
58
+ result = result.replace(/\x00(\d+)\x00/g, (_, i) => preserved[parseInt(i)]);
59
+
60
+ return result.trim();
61
+ }
62
+
63
+ /**
64
+ * Split text at sentence boundaries, keeping chunks under maxChars.
65
+ *
66
+ * Sentences end with `.`, `!`, or `?` followed by a space or end of string.
67
+ * If no sentence boundary is found within maxChars, splits at maxChars.
68
+ */
69
+ function splitAtSentenceBoundaries(text: string, maxChars: number): string[] {
70
+ const chunks: string[] = [];
71
+ let remaining = text;
72
+
73
+ while (remaining.length > maxChars) {
74
+ // Look for the last sentence boundary within maxChars
75
+ const window = remaining.slice(0, maxChars);
76
+ const sentenceEndRegex = /[.!?](?:\s|$)/g;
77
+ let lastBoundary = -1;
78
+ let match;
79
+
80
+ while ((match = sentenceEndRegex.exec(window)) !== null) {
81
+ // Include the punctuation mark in the split position
82
+ lastBoundary = match.index + 1;
83
+ }
84
+
85
+ if (lastBoundary > 0) {
86
+ chunks.push(remaining.slice(0, lastBoundary).trim());
87
+ remaining = remaining.slice(lastBoundary).trim();
88
+ } else {
89
+ // No sentence boundary found — hard split at maxChars
90
+ chunks.push(remaining.slice(0, maxChars).trim());
91
+ remaining = remaining.slice(maxChars).trim();
92
+ }
93
+ }
94
+
95
+ if (remaining.trim()) {
96
+ chunks.push(remaining.trim());
97
+ }
98
+
99
+ return chunks;
100
+ }
101
+
102
+ /**
103
+ * Derive a human-readable title from a slug.
104
+ * e.g. "getting-started" → "Getting Started"
105
+ */
106
+ function titleFromSlug(slug: string): string {
107
+ const lastSegment = slug.split('/').pop() || slug;
108
+ return lastSegment
109
+ .replace(/[-_]/g, ' ')
110
+ .replace(/\b\w/g, c => c.toUpperCase());
111
+ }
112
+
113
+ /**
114
+ * Detect if a page is an API reference page and return a prefix label.
115
+ * API pages get a prefix like "API Reference — POST /post\n" so the
116
+ * embedding model clusters them distinctly from guides/tutorials.
117
+ */
118
+ function getEmbeddingPrefix(
119
+ slug: string,
120
+ frontmatter: Record<string, unknown>,
121
+ ): string {
122
+ const apiMethod = (frontmatter.api as string) || (frontmatter.openapi as string);
123
+ if (apiMethod) return `API Reference — ${apiMethod}\n`;
124
+ if (slug.startsWith('apis/')) return 'API Reference\n';
125
+ return '';
126
+ }
127
+
128
+ /**
129
+ * Chunk a documentation page into embedding-sized pieces.
130
+ *
131
+ * @param page - Page with file path, raw MDX content, and frontmatter
132
+ * @param maxChars - Maximum characters per chunk (default 2000, ~500 tokens)
133
+ * @returns Array of embedding chunks with unique IDs
134
+ */
135
+ export function chunkPageForEmbedding(
136
+ page: { path: string; content: string; frontmatter: Record<string, unknown> },
137
+ maxChars = 2000,
138
+ ): EmbeddingChunk[] {
139
+ const slug = page.path.replace(/\.mdx?$/, '').replace(/\\/g, '/');
140
+ const pageTitle = (page.frontmatter.title as string) || titleFromSlug(slug);
141
+ const embeddingPrefix = getEmbeddingPrefix(slug, page.frontmatter);
142
+
143
+ // Normalize Windows line endings before extracting sections
144
+ const normalizedContent = page.content.replace(/\r\n/g, '\n');
145
+
146
+ const sections = extractSections(normalizedContent);
147
+ const chunks: EmbeddingChunk[] = [];
148
+ let chunkIndex = 0;
149
+
150
+ for (const section of sections) {
151
+ const cleanContent = stripForEmbedding(section.content);
152
+ if (!cleanContent.trim()) continue;
153
+
154
+ // Small sections stay whole; oversized ones split at sentence boundaries
155
+ const pieces = cleanContent.length <= maxChars
156
+ ? [cleanContent]
157
+ : splitAtSentenceBoundaries(cleanContent, maxChars);
158
+
159
+ for (const piece of pieces) {
160
+ if (!piece.trim()) continue;
161
+ chunks.push({
162
+ id: `${slug}#${chunkIndex}`,
163
+ pageSlug: slug,
164
+ sectionHeading: section.heading,
165
+ content: embeddingPrefix + piece,
166
+ pageTitle,
167
+ });
168
+ chunkIndex++;
169
+ }
170
+ }
171
+
172
+ return chunks;
173
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Auto-generate chat starter questions from documentation structure.
3
+ *
4
+ * Sends page titles and API methods to Claude Haiku, which returns
5
+ * 4 contextually relevant questions new users would likely ask.
6
+ * Non-fatal: returns empty array on any error so builds aren't blocked.
7
+ *
8
+ * Uses temperature 0 for deterministic output — same docs produce the
9
+ * same questions, avoiding unnecessary cache invalidation between builds.
10
+ */
11
+ import { getAnthropicClient } from './anthropic-client.js';
12
+
13
+ const MAX_QUESTIONS = 4;
14
+ /** Cap summary lines to avoid excessive prompt size with large doc sets */
15
+ const MAX_SUMMARY_LINES = 100;
16
+ /** Abort if Haiku takes longer than this (don't block the build) */
17
+ const TIMEOUT_MS = 10_000;
18
+
19
+ type PageInfo = { path: string; frontmatter: Record<string, unknown> };
20
+
21
+ /**
22
+ * Build a concise summary of the documentation structure for the prompt.
23
+ * Includes page titles and API methods. For large doc sets (> MAX_SUMMARY_LINES),
24
+ * samples evenly across the list to avoid bias toward alphabetically-first pages.
25
+ */
26
+ export function buildDocSummary(pages: PageInfo[]): string {
27
+ let sampled = pages;
28
+ if (pages.length > MAX_SUMMARY_LINES) {
29
+ // Sample evenly: spread indices across the full range so the last page
30
+ // is always included (avoids bias toward alphabetically-first pages)
31
+ const lastIdx = pages.length - 1;
32
+ const step = lastIdx / (MAX_SUMMARY_LINES - 1);
33
+ sampled = Array.from({ length: MAX_SUMMARY_LINES }, (_, i) =>
34
+ pages[Math.min(Math.round(i * step), lastIdx)],
35
+ );
36
+ }
37
+
38
+ return sampled.map(page => {
39
+ const title = (page.frontmatter.title as string)
40
+ || page.path.replace(/\.mdx?$/, '').split('/').pop() || 'Untitled';
41
+ const apiMethod = (page.frontmatter.api as string) || (page.frontmatter.openapi as string) || '';
42
+ return apiMethod ? `- ${title} (${apiMethod})` : `- ${title}`;
43
+ }).join('\n');
44
+ }
45
+
46
+ /**
47
+ * Generate starter questions for the chat empty state using Haiku.
48
+ * Returns up to 4 questions, or empty array if generation fails.
49
+ */
50
+ export async function generateStarterQuestions(pages: PageInfo[]): Promise<string[]> {
51
+ const anthropic = getAnthropicClient();
52
+ if (!anthropic || pages.length === 0) return [];
53
+
54
+ const summary = buildDocSummary(pages);
55
+ const controller = new AbortController();
56
+ const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS);
57
+
58
+ try {
59
+ const response = await anthropic.messages.create(
60
+ {
61
+ model: 'claude-haiku-4-5-20251001',
62
+ max_tokens: 256,
63
+ temperature: 0,
64
+ messages: [{
65
+ role: 'user',
66
+ content: `You are generating starter questions for a documentation chatbot. Given this documentation structure, generate exactly ${MAX_QUESTIONS} questions that a new user would likely ask first.
67
+
68
+ Rules:
69
+ - Each question must be under 50 characters
70
+ - Cover different topics (getting started, API usage, features, integration)
71
+ - Be specific to THIS documentation, not generic
72
+ - Use natural language ("How do I..." not "Explain the...")
73
+ - Return ONLY a JSON array of strings, no other text
74
+
75
+ Documentation pages:
76
+ ${summary}`,
77
+ }],
78
+ },
79
+ { signal: controller.signal },
80
+ );
81
+
82
+ let text = response.content[0]?.type === 'text' ? response.content[0].text : '';
83
+ // Strip markdown code fences — Haiku sometimes wraps JSON in ```json ... ```
84
+ text = text.replace(/^```(?:json)?\s*\n?/i, '').replace(/\n?```\s*$/, '').trim();
85
+ const parsed = JSON.parse(text);
86
+ if (!Array.isArray(parsed)) return [];
87
+
88
+ return parsed
89
+ // Accept string items directly, or objects with a "question" field
90
+ .map((q) => typeof q === 'string' ? q : (q?.question as string) || null)
91
+ .filter((q): q is string => typeof q === 'string' && q.length > 0)
92
+ .slice(0, MAX_QUESTIONS);
93
+ } catch {
94
+ return [];
95
+ } finally {
96
+ clearTimeout(timeout);
97
+ }
98
+ }
@@ -193,7 +193,8 @@ export const ISR_PHASES = {
193
193
  copy_content: { label: 'Preparing content...', weight: 10 },
194
194
  // ISR mode: nextjs_build is instant (pages compiled on-demand by Vercel)
195
195
  nextjs_build: { label: 'Building documentation...', weight: 5 },
196
- r2_upload: { label: 'Uploading to CDN...', weight: 45 },
196
+ r2_upload: { label: 'Uploading to CDN...', weight: 40 },
197
+ embeddings: { label: 'Generating AI embeddings...', weight: 5 },
197
198
  vercel_purge: { label: 'Refreshing cache...', weight: 20 },
198
199
  cleanup: { label: 'Cleaning up...', weight: 5 },
199
200
  } as const;
@@ -287,6 +287,7 @@ export const INTERNAL_API_ROUTES = [
287
287
  '/api/assets', // Asset serving from R2 (app/api/assets/[...path])
288
288
  '/api/ev', // Analytics events (app/api/ev)
289
289
  '/api/isr-health', // Health check endpoint (app/api/isr-health)
290
+ '/api/chat', // Chat endpoint (app/api/chat/[project])
290
291
  '/api/mcp', // MCP endpoint (app/api/mcp/[project])
291
292
  '/api/og', // OG image generation (app/api/og)
292
293
  '/api/r2', // R2 content serving (app/api/r2/[project]/[...path])
@@ -394,6 +395,26 @@ export function getMcpApiPath(projectSlug: string): string {
394
395
  return `/api/mcp/${projectSlug}`;
395
396
  }
396
397
 
398
+ /**
399
+ * Check if this is a chat request that needs routing.
400
+ *
401
+ * @param pathname - Request pathname
402
+ * @returns true if this is a chat request
403
+ */
404
+ export function isChatRequest(pathname: string): boolean {
405
+ return pathname === '/_chat' || pathname === '/docs/_chat';
406
+ }
407
+
408
+ /**
409
+ * Get the chat API path for a project.
410
+ *
411
+ * @param projectSlug - Project identifier
412
+ * @returns Chat API route path
413
+ */
414
+ export function getChatApiPath(projectSlug: string): string {
415
+ return `/api/chat/${projectSlug}`;
416
+ }
417
+
397
418
  /**
398
419
  * Check if path needs trailing slash normalization.
399
420
  *
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Shared route helpers for resolving project URLs, docs paths, and tracking analytics.
3
+ * Used by the MCP route and the chat API route.
4
+ */
5
+ import { redis } from './redis';
6
+ import { isCustomDomain, parseRedisConfig } from './domain-helpers';
7
+
8
+ /**
9
+ * Resolve docsPath for a project ('' or '/docs').
10
+ * Queries Redis for projectCfg: or domainCfg: keys to determine hostAtDocs.
11
+ * Defaults to '/docs' (hostAtDocs=true) for safety — most Jamdesk sites use this.
12
+ */
13
+ export async function getDocsPath(projectSlug: string, originalHost: string): Promise<string> {
14
+ if (!redis) return '/docs';
15
+
16
+ try {
17
+ const key = isCustomDomain(originalHost) ? `domainCfg:${originalHost}` : `projectCfg:${projectSlug}`;
18
+ const cfg = parseRedisConfig(await redis.get(key));
19
+ if (cfg) {
20
+ return cfg.hostAtDocs !== false ? '/docs' : '';
21
+ }
22
+ } catch {
23
+ // Fall through to default
24
+ }
25
+
26
+ return '/docs';
27
+ }
28
+
29
+ /**
30
+ * Resolve base URL for a project.
31
+ * Uses resolved host for custom/forwarded domains, falls back to project.jamdesk.app.
32
+ */
33
+ export function getBaseUrl(projectSlug: string, resolvedHost: string): string {
34
+ const hostname = resolvedHost.split(':')[0];
35
+ return isCustomDomain(hostname)
36
+ ? `https://${hostname}`
37
+ : `https://${projectSlug}.jamdesk.app`;
38
+ }
39
+
40
+ /**
41
+ * Fire-and-forget analytics tracking.
42
+ */
43
+ export async function trackServerAnalytics(params: {
44
+ projectSlug: string;
45
+ type: string;
46
+ query: string;
47
+ resultsCount: number;
48
+ source: string;
49
+ }): Promise<void> {
50
+ const trackingUrl = process.env.TRACKING_URL || 'https://us-central1-jamdesk-prod.cloudfunctions.net/trackSearchAnalytics';
51
+ try {
52
+ await fetch(trackingUrl, {
53
+ method: 'POST',
54
+ headers: { 'Content-Type': 'application/json' },
55
+ body: JSON.stringify({
56
+ ...params,
57
+ sessionId: `${params.source}-${Date.now()}`,
58
+ }),
59
+ });
60
+ } catch {
61
+ // Silent failure - don't break caller if analytics fails
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Fire-and-forget chat analytics tracking.
67
+ * Sends enriched chat event data (token usage, quality signals) to the
68
+ * trackChatAnalytics Firebase Function for storage in Firestore chatEvents.
69
+ */
70
+ export async function trackChatAnalytics(params: {
71
+ projectSlug: string;
72
+ query: string;
73
+ resultsCount: number;
74
+ inputTokens: number;
75
+ outputTokens: number;
76
+ model: string;
77
+ hadExplicitCitations: boolean;
78
+ hasClarification: boolean;
79
+ durationMs: number;
80
+ userAgent?: string;
81
+ }): Promise<void> {
82
+ const trackingUrl = process.env.CHAT_TRACKING_URL || 'https://us-central1-jamdesk-prod.cloudfunctions.net/trackChatAnalytics';
83
+ const headers: Record<string, string> = { 'Content-Type': 'application/json' };
84
+ if (params.userAgent) {
85
+ headers['User-Agent'] = params.userAgent;
86
+ }
87
+ try {
88
+ await fetch(trackingUrl, {
89
+ method: 'POST',
90
+ headers,
91
+ body: JSON.stringify(params),
92
+ });
93
+ } catch {
94
+ // Silent failure
95
+ }
96
+ }
@@ -52,13 +52,119 @@ function normalizeSnippetPath(importPath: string): string {
52
52
  .replace(/^\//, '');
53
53
  }
54
54
 
55
+ /**
56
+ * Extract the call expression from an arrow function body.
57
+ * Handles both concise body (() => expr) and block body (() => { expr; }).
58
+ */
59
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
60
+ function extractCallFromArrow(arrow: any): any | undefined {
61
+ if (arrow.body.type === 'CallExpression') {
62
+ return arrow.body;
63
+ }
64
+
65
+ if (
66
+ arrow.body.type === 'BlockStatement' &&
67
+ arrow.body.body.length === 1 &&
68
+ arrow.body.body[0].type === 'ExpressionStatement' &&
69
+ arrow.body.body[0].expression.type === 'CallExpression'
70
+ ) {
71
+ return arrow.body.body[0].expression;
72
+ }
73
+
74
+ return undefined;
75
+ }
76
+
77
+ /**
78
+ * Check if a call expression is window.open(stringLiteral, '_self').
79
+ * Returns the URL string if it matches, undefined otherwise.
80
+ */
81
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
82
+ function extractWindowOpenSelfUrl(call: any): string | undefined {
83
+ const callee = call.callee;
84
+ if (
85
+ callee.type !== 'MemberExpression' ||
86
+ callee.object?.name !== 'window' ||
87
+ callee.property?.name !== 'open' ||
88
+ call.arguments.length < 2
89
+ ) return undefined;
90
+
91
+ const urlArg = call.arguments[0];
92
+ const targetArg = call.arguments[1];
93
+ if (urlArg.type !== 'StringLiteral') return undefined;
94
+ if (targetArg.type !== 'StringLiteral' || targetArg.value !== '_self') return undefined;
95
+
96
+ return urlArg.value;
97
+ }
98
+
99
+ /**
100
+ * Babel plugin that transforms onClick navigation patterns for RSC compatibility.
101
+ *
102
+ * Converts: <span onClick={() => window.open('url', '_self')} ...>
103
+ * To: <a href="url" ...>
104
+ *
105
+ * Uses AST instead of regex for robustness (handles any attribute ordering,
106
+ * nesting depth, and whitespace). Matches the behavior of the static-mode
107
+ * regex in compile-snippets.cjs:158-174.
108
+ *
109
+ * Only targets <span> elements with the specific window.open(url, '_self')
110
+ * pattern (matching static-mode behavior) — other elements and event handlers
111
+ * are left unchanged (documented ISR limitation in SNIPPETS.md).
112
+ */
113
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
114
+ function onClickToHrefPlugin({ types: t }: { types: any }) {
115
+ return {
116
+ visitor: {
117
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
118
+ JSXElement(path: any) {
119
+ const opening = path.node.openingElement;
120
+
121
+ if (opening.name.type !== 'JSXIdentifier' || opening.name.name !== 'span') return;
122
+
123
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
124
+ const onClickIndex = opening.attributes.findIndex((attr: any) =>
125
+ attr.type === 'JSXAttribute' &&
126
+ attr.name?.type === 'JSXIdentifier' &&
127
+ attr.name.name === 'onClick'
128
+ );
129
+ if (onClickIndex === -1) return;
130
+
131
+ const onClickAttr = opening.attributes[onClickIndex];
132
+ if (onClickAttr.value?.type !== 'JSXExpressionContainer') return;
133
+
134
+ const arrow = onClickAttr.value.expression;
135
+ if (arrow.type !== 'ArrowFunctionExpression') return;
136
+
137
+ const call = extractCallFromArrow(arrow);
138
+ if (!call) return;
139
+
140
+ const url = extractWindowOpenSelfUrl(call);
141
+ if (url === undefined) return;
142
+
143
+ // Transform: <span onClick={...}> → <a href="url">
144
+ opening.name.name = 'a';
145
+ if (path.node.closingElement) {
146
+ path.node.closingElement.name.name = 'a';
147
+ }
148
+
149
+ opening.attributes.splice(onClickIndex, 1);
150
+ opening.attributes.unshift(
151
+ t.jsxAttribute(t.jsxIdentifier('href'), t.stringLiteral(url))
152
+ );
153
+ },
154
+ },
155
+ };
156
+ }
157
+
55
158
  /**
56
159
  * Transpile JSX source code to JavaScript.
57
160
  */
58
161
  function transpileJsx(source: string): string {
59
162
  const result = transform(source, {
60
163
  presets: ['react', 'typescript'],
61
- plugins: [['transform-react-jsx', { runtime: 'automatic', importSource: 'react' }]],
164
+ plugins: [
165
+ onClickToHrefPlugin,
166
+ ['transform-react-jsx', { runtime: 'automatic', importSource: 'react' }],
167
+ ],
62
168
  filename: 'snippet.tsx',
63
169
  });
64
170
 
@@ -291,7 +291,7 @@ function inferPageType(slug: string): string {
291
291
  /**
292
292
  * Strip markdown formatting from text.
293
293
  */
294
- function stripMarkdown(text: string): string {
294
+ export function stripMarkdown(text: string): string {
295
295
  return text
296
296
  .replace(/```[\s\S]*?```/g, '') // code blocks
297
297
  .replace(/`[^`]+`/g, '') // inline code
@@ -299,6 +299,7 @@ function stripMarkdown(text: string): string {
299
299
  .replace(/\*\*([^*]+)\*\*/g, '$1') // bold
300
300
  .replace(/\*([^*]+)\*/g, '$1') // italic
301
301
  .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // links
302
+ .replace(/<[A-Z][^>]*(?:\/>|>[\s\S]*?<\/[A-Z][^>]*>)/g, '') // MDX components (e.g. <Callout>, <Steps>)
302
303
  .replace(/<[^>]+>/g, '') // HTML tags
303
304
  .replace(/!\[([^\]]*)\]\([^)]+\)/g, '') // images
304
305
  .replace(/\n+/g, ' ') // newlines
@@ -308,7 +309,7 @@ function stripMarkdown(text: string): string {
308
309
  /**
309
310
  * Extract sections from MDX content.
310
311
  */
311
- function extractSections(content: string): Array<{ heading: string; content: string }> {
312
+ export function extractSections(content: string): Array<{ heading: string; content: string }> {
312
313
  const sections: Array<{ heading: string; content: string }> = [];
313
314
  const lines = content.split('\n');
314
315
  let currentHeading = '';
@@ -21,6 +21,7 @@ import { LIB_DIR } from './paths.js';
21
21
  // Types
22
22
  export interface DocsConfig {
23
23
  name: string;
24
+ projectId?: string;
24
25
  theme?: 'jam' | 'nebula' | 'pulsar';
25
26
  navigation: NavigationConfig;
26
27
  integrations?: IntegrationsConfig;