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.
- package/README.md +89 -4
- package/dist/__tests__/unit/auth.test.d.ts +2 -0
- package/dist/__tests__/unit/auth.test.d.ts.map +1 -0
- package/dist/__tests__/unit/auth.test.js +169 -0
- package/dist/__tests__/unit/auth.test.js.map +1 -0
- package/dist/__tests__/unit/config.test.d.ts +2 -0
- package/dist/__tests__/unit/config.test.d.ts.map +1 -0
- package/dist/__tests__/unit/config.test.js +76 -0
- package/dist/__tests__/unit/config.test.js.map +1 -0
- package/dist/__tests__/unit/deploy.test.d.ts +2 -0
- package/dist/__tests__/unit/deploy.test.d.ts.map +1 -0
- package/dist/__tests__/unit/deploy.test.js +273 -0
- package/dist/__tests__/unit/deploy.test.js.map +1 -0
- package/dist/__tests__/unit/deps-sync.test.js +3 -1
- package/dist/__tests__/unit/deps-sync.test.js.map +1 -1
- package/dist/__tests__/unit/dev-loading-server.test.d.ts +2 -0
- package/dist/__tests__/unit/dev-loading-server.test.d.ts.map +1 -0
- package/dist/__tests__/unit/dev-loading-server.test.js +141 -0
- package/dist/__tests__/unit/dev-loading-server.test.js.map +1 -0
- package/dist/__tests__/unit/docs-json-writer.test.d.ts +2 -0
- package/dist/__tests__/unit/docs-json-writer.test.d.ts.map +1 -0
- package/dist/__tests__/unit/docs-json-writer.test.js +71 -0
- package/dist/__tests__/unit/docs-json-writer.test.js.map +1 -0
- package/dist/__tests__/unit/loading-page.test.d.ts +2 -0
- package/dist/__tests__/unit/loading-page.test.d.ts.map +1 -0
- package/dist/__tests__/unit/loading-page.test.js +73 -0
- package/dist/__tests__/unit/loading-page.test.js.map +1 -0
- package/dist/__tests__/unit/login.test.d.ts +2 -0
- package/dist/__tests__/unit/login.test.d.ts.map +1 -0
- package/dist/__tests__/unit/login.test.js +100 -0
- package/dist/__tests__/unit/login.test.js.map +1 -0
- package/dist/__tests__/unit/logout.test.d.ts +2 -0
- package/dist/__tests__/unit/logout.test.d.ts.map +1 -0
- package/dist/__tests__/unit/logout.test.js +39 -0
- package/dist/__tests__/unit/logout.test.js.map +1 -0
- package/dist/__tests__/unit/tarball.test.d.ts +2 -0
- package/dist/__tests__/unit/tarball.test.d.ts.map +1 -0
- package/dist/__tests__/unit/tarball.test.js +126 -0
- package/dist/__tests__/unit/tarball.test.js.map +1 -0
- package/dist/__tests__/unit/whoami.test.d.ts +2 -0
- package/dist/__tests__/unit/whoami.test.d.ts.map +1 -0
- package/dist/__tests__/unit/whoami.test.js +47 -0
- package/dist/__tests__/unit/whoami.test.js.map +1 -0
- package/dist/commands/deploy.d.ts +13 -0
- package/dist/commands/deploy.d.ts.map +1 -0
- package/dist/commands/deploy.js +265 -0
- package/dist/commands/deploy.js.map +1 -0
- package/dist/commands/dev.d.ts.map +1 -1
- package/dist/commands/dev.js +48 -25
- package/dist/commands/dev.js.map +1 -1
- package/dist/commands/login.d.ts +8 -0
- package/dist/commands/login.d.ts.map +1 -0
- package/dist/commands/login.js +135 -0
- package/dist/commands/login.js.map +1 -0
- package/dist/commands/logout.d.ts +5 -0
- package/dist/commands/logout.d.ts.map +1 -0
- package/dist/commands/logout.js +17 -0
- package/dist/commands/logout.js.map +1 -0
- package/dist/commands/whoami.d.ts +5 -0
- package/dist/commands/whoami.d.ts.map +1 -0
- package/dist/commands/whoami.js +24 -0
- package/dist/commands/whoami.js.map +1 -0
- package/dist/index.js +50 -7
- package/dist/index.js.map +1 -1
- package/dist/lib/auth.d.ts +34 -0
- package/dist/lib/auth.d.ts.map +1 -0
- package/dist/lib/auth.js +105 -0
- package/dist/lib/auth.js.map +1 -0
- package/dist/lib/config.d.ts +9 -0
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/config.js +7 -1
- package/dist/lib/config.js.map +1 -1
- package/dist/lib/dev-loading-server.d.ts +22 -0
- package/dist/lib/dev-loading-server.d.ts.map +1 -0
- package/dist/lib/dev-loading-server.js +117 -0
- package/dist/lib/dev-loading-server.js.map +1 -0
- package/dist/lib/docs-config.d.ts +1 -0
- package/dist/lib/docs-config.d.ts.map +1 -1
- package/dist/lib/docs-config.js.map +1 -1
- package/dist/lib/docs-json-writer.d.ts +2 -0
- package/dist/lib/docs-json-writer.d.ts.map +1 -0
- package/dist/lib/docs-json-writer.js +35 -0
- package/dist/lib/docs-json-writer.js.map +1 -0
- package/dist/lib/loading-page.d.ts +11 -0
- package/dist/lib/loading-page.d.ts.map +1 -0
- package/dist/lib/loading-page.js +222 -0
- package/dist/lib/loading-page.js.map +1 -0
- package/dist/lib/output.d.ts +13 -5
- package/dist/lib/output.d.ts.map +1 -1
- package/dist/lib/output.js +22 -5
- package/dist/lib/output.js.map +1 -1
- package/dist/lib/tarball.d.ts +28 -0
- package/dist/lib/tarball.d.ts.map +1 -0
- package/dist/lib/tarball.js +117 -0
- package/dist/lib/tarball.js.map +1 -0
- package/package.json +5 -2
- package/vendored/app/[[...slug]]/page.tsx +6 -20
- package/vendored/app/api/chat/[project]/route.ts +323 -0
- package/vendored/app/api/mcp/[project]/route.ts +2 -63
- package/vendored/components/chat/ChatCodeBlock.tsx +63 -0
- package/vendored/components/chat/ChatEmptyState.tsx +79 -0
- package/vendored/components/chat/ChatFAB.tsx +36 -0
- package/vendored/components/chat/ChatInput.tsx +106 -0
- package/vendored/components/chat/ChatMessage.tsx +176 -0
- package/vendored/components/chat/ChatPanel.tsx +206 -0
- package/vendored/components/chat/ChatResizeHandle.tsx +108 -0
- package/vendored/components/chat/LazyChatPanel.tsx +19 -0
- package/vendored/components/layout/LayoutWrapper.tsx +134 -44
- package/vendored/components/layout/PageColumns.tsx +40 -0
- package/vendored/components/navigation/Header.tsx +74 -29
- package/vendored/components/navigation/Sidebar.tsx +17 -2
- package/vendored/hooks/useChat.ts +335 -0
- package/vendored/hooks/useChatPanel.tsx +101 -0
- package/vendored/lib/anthropic-client.ts +19 -0
- package/vendored/lib/build/extract-tarball.ts +150 -0
- package/vendored/lib/chat-prompt.ts +56 -0
- package/vendored/lib/docs-types.ts +14 -0
- package/vendored/lib/docs.ts +22 -4
- package/vendored/lib/embedding-chunker.ts +173 -0
- package/vendored/lib/generate-starter-questions.ts +98 -0
- package/vendored/lib/isr-build-executor.ts +2 -1
- package/vendored/lib/middleware-helpers.ts +21 -0
- package/vendored/lib/route-helpers.ts +96 -0
- package/vendored/lib/snippet-loader-isr.ts +107 -1
- package/vendored/lib/static-artifacts.ts +3 -2
- package/vendored/lib/validate-config.ts +1 -0
- package/vendored/lib/vector-store.ts +213 -0
- package/vendored/schema/docs-schema.json +33 -0
- package/vendored/scripts/dev-project.cjs +6 -0
- package/vendored/shared/types.ts +6 -5
- package/vendored/tailwind.config.ts +9 -0
- package/vendored/themes/jam/variables.css +2 -2
package/vendored/lib/docs.ts
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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: [
|
|
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 = '';
|