skrypt-ai 0.5.0 → 0.6.0
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/auth/index.js +8 -1
- package/dist/autofix/index.d.ts +0 -4
- package/dist/autofix/index.js +0 -21
- package/dist/capture/browser.d.ts +11 -0
- package/dist/capture/browser.js +173 -0
- package/dist/capture/diff.d.ts +23 -0
- package/dist/capture/diff.js +52 -0
- package/dist/capture/index.d.ts +23 -0
- package/dist/capture/index.js +210 -0
- package/dist/capture/naming.d.ts +17 -0
- package/dist/capture/naming.js +45 -0
- package/dist/capture/parser.d.ts +15 -0
- package/dist/capture/parser.js +80 -0
- package/dist/capture/types.d.ts +57 -0
- package/dist/capture/types.js +1 -0
- package/dist/cli.js +4 -0
- package/dist/commands/autofix.js +136 -120
- package/dist/commands/cron.js +58 -47
- package/dist/commands/deploy.js +123 -102
- package/dist/commands/generate.js +88 -6
- package/dist/commands/heal.d.ts +10 -0
- package/dist/commands/heal.js +201 -0
- package/dist/commands/i18n.js +146 -111
- package/dist/commands/lint.js +50 -44
- package/dist/commands/llms-txt.js +59 -49
- package/dist/commands/login.js +61 -43
- package/dist/commands/mcp.js +6 -0
- package/dist/commands/monitor.js +13 -8
- package/dist/commands/qa.d.ts +2 -0
- package/dist/commands/qa.js +43 -0
- package/dist/commands/review-pr.js +108 -102
- package/dist/commands/sdk.js +128 -122
- package/dist/commands/security.js +86 -80
- package/dist/commands/test.js +91 -92
- package/dist/commands/version.js +104 -75
- package/dist/commands/watch.js +130 -114
- package/dist/config/types.js +2 -2
- package/dist/context-hub/index.d.ts +23 -0
- package/dist/context-hub/index.js +179 -0
- package/dist/context-hub/mappings.d.ts +8 -0
- package/dist/context-hub/mappings.js +55 -0
- package/dist/context-hub/types.d.ts +33 -0
- package/dist/context-hub/types.js +1 -0
- package/dist/generator/generator.js +39 -6
- package/dist/generator/types.d.ts +7 -0
- package/dist/generator/writer.d.ts +3 -1
- package/dist/generator/writer.js +24 -4
- package/dist/llm/anthropic-client.d.ts +1 -0
- package/dist/llm/anthropic-client.js +3 -1
- package/dist/llm/index.d.ts +6 -4
- package/dist/llm/index.js +76 -261
- package/dist/llm/openai-client.d.ts +1 -0
- package/dist/llm/openai-client.js +7 -2
- package/dist/qa/checks.d.ts +10 -0
- package/dist/qa/checks.js +492 -0
- package/dist/qa/fixes.d.ts +30 -0
- package/dist/qa/fixes.js +277 -0
- package/dist/qa/index.d.ts +29 -0
- package/dist/qa/index.js +187 -0
- package/dist/qa/types.d.ts +24 -0
- package/dist/qa/types.js +1 -0
- package/dist/scanner/csharp.d.ts +23 -0
- package/dist/scanner/csharp.js +421 -0
- package/dist/scanner/index.js +16 -2
- package/dist/scanner/java.d.ts +39 -0
- package/dist/scanner/java.js +318 -0
- package/dist/scanner/kotlin.d.ts +23 -0
- package/dist/scanner/kotlin.js +389 -0
- package/dist/scanner/php.d.ts +57 -0
- package/dist/scanner/php.js +351 -0
- package/dist/scanner/ruby.d.ts +36 -0
- package/dist/scanner/ruby.js +431 -0
- package/dist/scanner/swift.d.ts +25 -0
- package/dist/scanner/swift.js +392 -0
- package/dist/scanner/types.d.ts +1 -1
- package/dist/template/content/docs/_navigation.json +46 -0
- package/dist/template/content/docs/_sidebars.json +684 -0
- package/dist/template/content/docs/core.md +4544 -0
- package/dist/template/content/docs/index.mdx +89 -0
- package/dist/template/content/docs/integrations.md +1158 -0
- package/dist/template/content/docs/llms-full.md +403 -0
- package/dist/template/content/docs/llms.txt +4588 -0
- package/dist/template/content/docs/other.md +10379 -0
- package/dist/template/content/docs/tools.md +746 -0
- package/dist/template/content/docs/types.md +531 -0
- package/dist/template/docs.json +13 -11
- package/dist/template/mdx-components.tsx +27 -2
- package/dist/template/package.json +6 -0
- package/dist/template/public/search-index.json +1 -1
- package/dist/template/scripts/build-search-index.mjs +84 -6
- package/dist/template/src/app/api/chat/route.ts +83 -128
- package/dist/template/src/app/docs/[...slug]/page.tsx +75 -20
- package/dist/template/src/app/docs/llms-full.md +151 -4
- package/dist/template/src/app/docs/llms.txt +2464 -847
- package/dist/template/src/app/docs/page.mdx +48 -38
- package/dist/template/src/app/layout.tsx +3 -1
- package/dist/template/src/app/page.tsx +22 -8
- package/dist/template/src/components/ai-chat.tsx +73 -64
- package/dist/template/src/components/breadcrumbs.tsx +21 -23
- package/dist/template/src/components/copy-button.tsx +13 -9
- package/dist/template/src/components/copy-page-button.tsx +54 -0
- package/dist/template/src/components/docs-layout.tsx +37 -25
- package/dist/template/src/components/header.tsx +51 -10
- package/dist/template/src/components/mdx/card.tsx +17 -3
- package/dist/template/src/components/mdx/code-block.tsx +13 -9
- package/dist/template/src/components/mdx/code-group.tsx +13 -8
- package/dist/template/src/components/mdx/heading.tsx +15 -2
- package/dist/template/src/components/mdx/highlighted-code.tsx +13 -8
- package/dist/template/src/components/mdx/index.tsx +2 -0
- package/dist/template/src/components/mdx/mermaid.tsx +110 -0
- package/dist/template/src/components/mdx/screenshot.tsx +150 -0
- package/dist/template/src/components/scroll-to-hash.tsx +48 -0
- package/dist/template/src/components/sidebar.tsx +12 -18
- package/dist/template/src/components/table-of-contents.tsx +9 -0
- package/dist/template/src/lib/highlight.ts +3 -88
- package/dist/template/src/lib/navigation.ts +159 -0
- package/dist/template/src/styles/globals.css +17 -6
- package/dist/utils/validation.d.ts +0 -3
- package/dist/utils/validation.js +0 -26
- package/package.json +3 -2
|
@@ -86,6 +86,55 @@ function extractPlainText(content) {
|
|
|
86
86
|
.trim()
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
/**
|
|
90
|
+
* Slugify a heading for use as an anchor hash.
|
|
91
|
+
* Matches the logic in heading components (H2, H3).
|
|
92
|
+
*/
|
|
93
|
+
function slugifyHeading(text) {
|
|
94
|
+
return text
|
|
95
|
+
.replace(/`/g, '')
|
|
96
|
+
.toLowerCase()
|
|
97
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
98
|
+
.replace(/(^-|-$)/g, '')
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Split content into sections by h2 headings.
|
|
103
|
+
* Returns an array of { heading, slug, content } objects.
|
|
104
|
+
*/
|
|
105
|
+
function splitBySections(content) {
|
|
106
|
+
const sections = []
|
|
107
|
+
const h2Regex = /^## (.+)$/gm
|
|
108
|
+
let lastIndex = 0
|
|
109
|
+
let lastHeading = null
|
|
110
|
+
let lastSlug = null
|
|
111
|
+
let match
|
|
112
|
+
|
|
113
|
+
while ((match = h2Regex.exec(content)) !== null) {
|
|
114
|
+
if (lastHeading !== null) {
|
|
115
|
+
sections.push({
|
|
116
|
+
heading: lastHeading,
|
|
117
|
+
slug: lastSlug,
|
|
118
|
+
content: content.slice(lastIndex, match.index),
|
|
119
|
+
})
|
|
120
|
+
}
|
|
121
|
+
lastHeading = match[1].replace(/[`*_[\]]/g, '').trim()
|
|
122
|
+
lastSlug = slugifyHeading(lastHeading)
|
|
123
|
+
lastIndex = match.index
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Last section
|
|
127
|
+
if (lastHeading !== null) {
|
|
128
|
+
sections.push({
|
|
129
|
+
heading: lastHeading,
|
|
130
|
+
slug: lastSlug,
|
|
131
|
+
content: content.slice(lastIndex),
|
|
132
|
+
})
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return sections
|
|
136
|
+
}
|
|
137
|
+
|
|
89
138
|
/**
|
|
90
139
|
* Extract keywords from content — important terms that appear frequently
|
|
91
140
|
* or in headings, used to boost search relevance.
|
|
@@ -119,7 +168,7 @@ function getSlugFromContentPath(filePath) {
|
|
|
119
168
|
const rel = relative(CONTENT_DIR, filePath)
|
|
120
169
|
const slug = rel
|
|
121
170
|
.replace(/\.(mdx?|md)$/, '')
|
|
122
|
-
.replace(
|
|
171
|
+
.replace(/(^|\/)index$/, '')
|
|
123
172
|
.replace(/\\/g, '/')
|
|
124
173
|
|
|
125
174
|
return slug ? `/docs/${slug}` : '/docs'
|
|
@@ -186,19 +235,26 @@ async function buildSearchIndex() {
|
|
|
186
235
|
if (await dirExists(CONTENT_DIR)) {
|
|
187
236
|
const contentFiles = await getAllMDXFiles(CONTENT_DIR)
|
|
188
237
|
for (const file of contentFiles) {
|
|
238
|
+
// Skip metadata files
|
|
239
|
+
const fileName = basename(file).replace(/\.(mdx?|md)$/, '')
|
|
240
|
+
if (fileName.startsWith('_') || fileName.startsWith('llms') || fileName === 'README') continue
|
|
241
|
+
|
|
189
242
|
try {
|
|
190
243
|
const raw = await readFile(file, 'utf-8')
|
|
191
244
|
const { data, content } = matter(raw)
|
|
192
245
|
|
|
193
246
|
const title = data.title || extractTitle(content, file)
|
|
194
|
-
const plainContent = extractPlainText(content)
|
|
195
|
-
const headings = extractHeadings(content)
|
|
196
|
-
const keywords = extractKeywords(content, title)
|
|
197
247
|
const href = getSlugFromContentPath(file)
|
|
198
248
|
|
|
199
249
|
if (seen.has(href)) continue
|
|
200
250
|
seen.add(href)
|
|
201
251
|
|
|
252
|
+
// Index the page itself
|
|
253
|
+
const plainContent = extractPlainText(content)
|
|
254
|
+
const headings = extractHeadings(content)
|
|
255
|
+
const keywords = extractKeywords(content, title)
|
|
256
|
+
const sectionLabel = data.section || data.category || title
|
|
257
|
+
|
|
202
258
|
documents.push({
|
|
203
259
|
id: href,
|
|
204
260
|
title,
|
|
@@ -206,10 +262,30 @@ async function buildSearchIndex() {
|
|
|
206
262
|
keywords,
|
|
207
263
|
content: plainContent.slice(0, 5000),
|
|
208
264
|
href,
|
|
209
|
-
section:
|
|
265
|
+
section: sectionLabel,
|
|
210
266
|
})
|
|
211
267
|
|
|
212
|
-
|
|
268
|
+
// Also index individual h2 sections as separate search documents
|
|
269
|
+
// This enables deep-linking search results (e.g. /docs/core#autofixbatch)
|
|
270
|
+
const sections = splitBySections(content)
|
|
271
|
+
for (const sec of sections) {
|
|
272
|
+
const sectionHref = `${href}#${sec.slug}`
|
|
273
|
+
if (seen.has(sectionHref)) continue
|
|
274
|
+
seen.add(sectionHref)
|
|
275
|
+
|
|
276
|
+
const sectionContent = extractPlainText(sec.content).slice(0, 2000)
|
|
277
|
+
documents.push({
|
|
278
|
+
id: sectionHref,
|
|
279
|
+
title: sec.heading,
|
|
280
|
+
headings: '',
|
|
281
|
+
keywords: sec.heading.toLowerCase(),
|
|
282
|
+
content: sectionContent,
|
|
283
|
+
href: sectionHref,
|
|
284
|
+
section: title, // parent page title as section label
|
|
285
|
+
})
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
console.log(` Indexed: ${href} (content/) + ${sections.length} sections`)
|
|
213
289
|
} catch (err) {
|
|
214
290
|
console.error(` Error indexing ${file}:`, err.message)
|
|
215
291
|
}
|
|
@@ -221,6 +297,8 @@ async function buildSearchIndex() {
|
|
|
221
297
|
const appFiles = await getAllMDXFiles(APP_DOCS_DIR)
|
|
222
298
|
for (const file of appFiles) {
|
|
223
299
|
if (file.includes('[...slug]') || file.includes('layout.')) continue
|
|
300
|
+
const appFileName = basename(file).replace(/\.(mdx?|md)$/, '')
|
|
301
|
+
if (appFileName.startsWith('_') || appFileName.startsWith('llms') || appFileName === 'README') continue
|
|
224
302
|
|
|
225
303
|
try {
|
|
226
304
|
const raw = await readFile(file, 'utf-8')
|
|
@@ -1,10 +1,24 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { streamText, type CoreMessage } from 'ai'
|
|
2
|
+
import { createOpenAI } from '@ai-sdk/openai'
|
|
3
|
+
import { createAnthropic } from '@ai-sdk/anthropic'
|
|
2
4
|
import { readFileSync, readdirSync, statSync, existsSync } from 'fs'
|
|
3
5
|
import { join, extname } from 'path'
|
|
4
6
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
7
|
+
// In-memory rate limiter
|
|
8
|
+
const rateLimitMap = new Map<string, { count: number; resetAt: number }>()
|
|
9
|
+
const RATE_LIMIT = 20
|
|
10
|
+
const RATE_WINDOW = 60_000
|
|
11
|
+
|
|
12
|
+
function checkRateLimit(ip: string): boolean {
|
|
13
|
+
const now = Date.now()
|
|
14
|
+
const entry = rateLimitMap.get(ip)
|
|
15
|
+
if (!entry || now > entry.resetAt) {
|
|
16
|
+
rateLimitMap.set(ip, { count: 1, resetAt: now + RATE_WINDOW })
|
|
17
|
+
return true
|
|
18
|
+
}
|
|
19
|
+
if (entry.count >= RATE_LIMIT) return false
|
|
20
|
+
entry.count++
|
|
21
|
+
return true
|
|
8
22
|
}
|
|
9
23
|
|
|
10
24
|
interface DocChunk {
|
|
@@ -19,13 +33,12 @@ let docChunks: DocChunk[] | null = null
|
|
|
19
33
|
let lastIndexTime = 0
|
|
20
34
|
|
|
21
35
|
function indexDocs(): DocChunk[] {
|
|
22
|
-
// Return cached if recent (5 min)
|
|
23
36
|
if (docChunks && Date.now() - lastIndexTime < 5 * 60 * 1000) {
|
|
24
37
|
return docChunks
|
|
25
38
|
}
|
|
26
39
|
|
|
27
40
|
const chunks: DocChunk[] = []
|
|
28
|
-
const docsDir = join(process.cwd(), '
|
|
41
|
+
const docsDir = join(process.cwd(), 'content/docs')
|
|
29
42
|
|
|
30
43
|
if (!existsSync(docsDir)) {
|
|
31
44
|
return chunks
|
|
@@ -45,7 +58,6 @@ function indexDocs(): DocChunk[] {
|
|
|
45
58
|
const content = readFileSync(fullPath, 'utf-8')
|
|
46
59
|
const docPath = `${basePath}/${entry.replace(/\.(mdx?|md)$/, '')}`.replace('/page', '')
|
|
47
60
|
|
|
48
|
-
// Extract title from frontmatter or first heading
|
|
49
61
|
let title = entry.replace(/\.(mdx?|md)$/, '')
|
|
50
62
|
const titleMatch = content.match(/title:\s*["']?([^"'\n]+)["']?/m)
|
|
51
63
|
if (titleMatch) {
|
|
@@ -55,16 +67,14 @@ function indexDocs(): DocChunk[] {
|
|
|
55
67
|
if (h1Match) title = h1Match[1]
|
|
56
68
|
}
|
|
57
69
|
|
|
58
|
-
// Extract headings
|
|
59
70
|
const headings = [...content.matchAll(/^#{1,3}\s+(.+)$/gm)].map(m => m[1])
|
|
60
71
|
|
|
61
|
-
// Clean content (remove frontmatter, code blocks for search)
|
|
62
72
|
let cleanContent = content
|
|
63
|
-
.replace(/^---[\s\S]*?---/m, '')
|
|
64
|
-
.replace(/```[\s\S]*?```/g, '')
|
|
65
|
-
.replace(/<[^>]+>/g, '')
|
|
66
|
-
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
|
|
67
|
-
.slice(0, 2000)
|
|
73
|
+
.replace(/^---[\s\S]*?---/m, '')
|
|
74
|
+
.replace(/```[\s\S]*?```/g, '')
|
|
75
|
+
.replace(/<[^>]+>/g, '')
|
|
76
|
+
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
|
|
77
|
+
.slice(0, 2000)
|
|
68
78
|
|
|
69
79
|
chunks.push({
|
|
70
80
|
path: docPath || '/',
|
|
@@ -100,13 +110,11 @@ function searchDocs(query: string, chunks: DocChunk[]): DocChunk[] {
|
|
|
100
110
|
const contentLower = chunk.content.toLowerCase()
|
|
101
111
|
const titleLower = chunk.title.toLowerCase()
|
|
102
112
|
|
|
103
|
-
// Title matches (high weight)
|
|
104
113
|
if (titleLower.includes(queryLower)) score += 20
|
|
105
114
|
queryWords.forEach(word => {
|
|
106
115
|
if (titleLower.includes(word)) score += 5
|
|
107
116
|
})
|
|
108
117
|
|
|
109
|
-
// Heading matches (medium weight)
|
|
110
118
|
chunk.headings.forEach(h => {
|
|
111
119
|
const hLower = h.toLowerCase()
|
|
112
120
|
if (hLower.includes(queryLower)) score += 10
|
|
@@ -115,10 +123,9 @@ function searchDocs(query: string, chunks: DocChunk[]): DocChunk[] {
|
|
|
115
123
|
})
|
|
116
124
|
})
|
|
117
125
|
|
|
118
|
-
// Content matches (lower weight)
|
|
119
126
|
queryWords.forEach(word => {
|
|
120
127
|
const matches = (contentLower.match(new RegExp(escapeRegex(word), 'g')) || []).length
|
|
121
|
-
score += Math.min(matches, 5)
|
|
128
|
+
score += Math.min(matches, 5)
|
|
122
129
|
})
|
|
123
130
|
|
|
124
131
|
return { chunk, score }
|
|
@@ -129,137 +136,85 @@ function searchDocs(query: string, chunks: DocChunk[]): DocChunk[] {
|
|
|
129
136
|
.map(r => r.chunk)
|
|
130
137
|
}
|
|
131
138
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
): Promise<{ content: string; citations: Array<{ title: string; path: string; snippet: string }> }> {
|
|
137
|
-
// Check for API key
|
|
138
|
-
const apiKey = process.env.OPENAI_API_KEY || process.env.ANTHROPIC_API_KEY
|
|
139
|
-
|
|
140
|
-
if (!apiKey) {
|
|
141
|
-
// Fallback: return a simple search result without AI
|
|
142
|
-
if (context.length === 0) {
|
|
143
|
-
return {
|
|
144
|
-
content: "I couldn't find any relevant documentation for your question. Try rephrasing or browsing the docs directly.",
|
|
145
|
-
citations: [],
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
return {
|
|
150
|
-
content: `Based on the documentation, here's what I found:\n\n${context[0].content.slice(0, 500)}...\n\nSee the linked sources for more details.`,
|
|
151
|
-
citations: context.slice(0, 3).map(c => ({
|
|
152
|
-
title: c.title,
|
|
153
|
-
path: `/docs${c.path}`,
|
|
154
|
-
snippet: c.content.slice(0, 100),
|
|
155
|
-
})),
|
|
156
|
-
}
|
|
139
|
+
function getModel() {
|
|
140
|
+
if (process.env.OPENAI_API_KEY) {
|
|
141
|
+
const openai = createOpenAI({ apiKey: process.env.OPENAI_API_KEY })
|
|
142
|
+
return openai('gpt-4o-mini')
|
|
157
143
|
}
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
.map(c => `## ${c.title}\nPath: ${c.path}\n\n${c.content}`)
|
|
162
|
-
.join('\n\n---\n\n')
|
|
163
|
-
|
|
164
|
-
const systemPrompt = `You are a helpful documentation assistant. Answer questions based ONLY on the provided documentation context. If the answer isn't in the context, say so.
|
|
165
|
-
|
|
166
|
-
Always cite your sources by mentioning which document you're referencing. Be concise and helpful.
|
|
167
|
-
|
|
168
|
-
Documentation context:
|
|
169
|
-
${contextText}`
|
|
170
|
-
|
|
171
|
-
const messages = [
|
|
172
|
-
{ role: 'system' as const, content: systemPrompt },
|
|
173
|
-
...history.slice(-4), // Last 4 messages for context
|
|
174
|
-
{ role: 'user' as const, content: query },
|
|
175
|
-
]
|
|
176
|
-
|
|
177
|
-
try {
|
|
178
|
-
let answer: string
|
|
179
|
-
|
|
180
|
-
if (process.env.OPENAI_API_KEY) {
|
|
181
|
-
const response = await fetch('https://api.openai.com/v1/chat/completions', {
|
|
182
|
-
method: 'POST',
|
|
183
|
-
headers: {
|
|
184
|
-
'Authorization': `Bearer ${process.env.OPENAI_API_KEY}`,
|
|
185
|
-
'Content-Type': 'application/json',
|
|
186
|
-
},
|
|
187
|
-
body: JSON.stringify({
|
|
188
|
-
model: 'gpt-4o-mini',
|
|
189
|
-
messages,
|
|
190
|
-
max_tokens: 500,
|
|
191
|
-
}),
|
|
192
|
-
})
|
|
193
|
-
|
|
194
|
-
const data = await response.json()
|
|
195
|
-
answer = data.choices?.[0]?.message?.content || 'Sorry, I could not generate an answer.'
|
|
196
|
-
} else {
|
|
197
|
-
const response = await fetch('https://api.anthropic.com/v1/messages', {
|
|
198
|
-
method: 'POST',
|
|
199
|
-
headers: {
|
|
200
|
-
'x-api-key': process.env.ANTHROPIC_API_KEY!,
|
|
201
|
-
'anthropic-version': '2023-06-01',
|
|
202
|
-
'Content-Type': 'application/json',
|
|
203
|
-
},
|
|
204
|
-
body: JSON.stringify({
|
|
205
|
-
model: 'claude-sonnet-4-20250514',
|
|
206
|
-
max_tokens: 500,
|
|
207
|
-
system: systemPrompt,
|
|
208
|
-
messages: messages.filter(m => m.role !== 'system'),
|
|
209
|
-
}),
|
|
210
|
-
})
|
|
211
|
-
|
|
212
|
-
const data = await response.json()
|
|
213
|
-
answer = data.content?.[0]?.text || 'Sorry, I could not generate an answer.'
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
return {
|
|
217
|
-
content: answer,
|
|
218
|
-
citations: context.slice(0, 3).map(c => ({
|
|
219
|
-
title: c.title,
|
|
220
|
-
path: `/docs${c.path}`,
|
|
221
|
-
snippet: c.content.slice(0, 100),
|
|
222
|
-
})),
|
|
223
|
-
}
|
|
224
|
-
} catch (err) {
|
|
225
|
-
console.error('AI generation error:', err)
|
|
226
|
-
return {
|
|
227
|
-
content: 'Sorry, I encountered an error generating an answer. Please try again.',
|
|
228
|
-
citations: [],
|
|
229
|
-
}
|
|
144
|
+
if (process.env.ANTHROPIC_API_KEY) {
|
|
145
|
+
const anthropic = createAnthropic({ apiKey: process.env.ANTHROPIC_API_KEY })
|
|
146
|
+
return anthropic('claude-sonnet-4-20250514')
|
|
230
147
|
}
|
|
148
|
+
return null
|
|
231
149
|
}
|
|
232
150
|
|
|
233
151
|
export async function POST(request: Request) {
|
|
152
|
+
const ip = request.headers.get('x-forwarded-for') ?? request.headers.get('x-real-ip') ?? 'unknown'
|
|
153
|
+
if (!checkRateLimit(ip)) {
|
|
154
|
+
return new Response(JSON.stringify({ error: 'Rate limit exceeded' }), { status: 429 })
|
|
155
|
+
}
|
|
156
|
+
|
|
234
157
|
try {
|
|
235
158
|
const body = await request.json()
|
|
236
|
-
const { messages } = body as { messages:
|
|
159
|
+
const { messages } = body as { messages: CoreMessage[] }
|
|
237
160
|
|
|
238
161
|
if (!Array.isArray(messages) || messages.length === 0 || messages.length > 50) {
|
|
239
|
-
return
|
|
162
|
+
return new Response(JSON.stringify({ error: 'Invalid request' }), { status: 400 })
|
|
240
163
|
}
|
|
241
164
|
|
|
242
165
|
const lastMessage = messages[messages.length - 1]
|
|
243
166
|
if (typeof lastMessage?.content !== 'string' || lastMessage.content.length > 10000) {
|
|
244
|
-
return
|
|
167
|
+
return new Response(JSON.stringify({ error: 'Message too long' }), { status: 400 })
|
|
245
168
|
}
|
|
246
169
|
|
|
247
|
-
|
|
248
|
-
|
|
170
|
+
// Index and search docs
|
|
171
|
+
const chunks = indexDocs()
|
|
172
|
+
const relevantDocs = searchDocs(lastMessage.content as string, chunks)
|
|
173
|
+
|
|
174
|
+
const contextText = relevantDocs
|
|
175
|
+
.map(c => `## ${c.title}\nPath: ${c.path}\n\n${c.content}`)
|
|
176
|
+
.join('\n\n---\n\n')
|
|
177
|
+
|
|
178
|
+
const citations = relevantDocs.slice(0, 3).map(c => ({
|
|
179
|
+
title: c.title,
|
|
180
|
+
path: `/docs${c.path}`,
|
|
181
|
+
snippet: c.content.slice(0, 100),
|
|
182
|
+
}))
|
|
183
|
+
|
|
184
|
+
const model = getModel()
|
|
185
|
+
|
|
186
|
+
// No API key — return plain search results as a non-streaming response
|
|
187
|
+
if (!model) {
|
|
188
|
+
const fallback = relevantDocs.length > 0
|
|
189
|
+
? `Based on the docs:\n\n${relevantDocs[0].content.slice(0, 500)}...\n\nSee the linked sources for more.`
|
|
190
|
+
: "I couldn't find relevant documentation. Try rephrasing or browse the docs directly."
|
|
191
|
+
|
|
192
|
+
return new Response(JSON.stringify({ content: fallback, citations }), {
|
|
193
|
+
headers: { 'Content-Type': 'application/json' },
|
|
194
|
+
})
|
|
249
195
|
}
|
|
250
196
|
|
|
251
|
-
//
|
|
252
|
-
const
|
|
197
|
+
// Stream the response using Vercel AI SDK
|
|
198
|
+
const result = streamText({
|
|
199
|
+
model,
|
|
200
|
+
system: `You are a helpful documentation assistant. Answer questions based ONLY on the provided documentation context. If the answer isn't in the context, say so. Be concise. Use markdown formatting (bold, code blocks, lists) to make answers scannable.
|
|
253
201
|
|
|
254
|
-
|
|
255
|
-
|
|
202
|
+
Documentation context:
|
|
203
|
+
${contextText}
|
|
256
204
|
|
|
257
|
-
|
|
258
|
-
|
|
205
|
+
Sources available (reference these when relevant):
|
|
206
|
+
${citations.map(c => `- [${c.title}](${c.path})`).join('\n')}`,
|
|
207
|
+
messages: messages.slice(-5) as CoreMessage[],
|
|
208
|
+
maxTokens: 800,
|
|
209
|
+
})
|
|
259
210
|
|
|
260
|
-
return
|
|
211
|
+
return result.toDataStreamResponse({
|
|
212
|
+
headers: {
|
|
213
|
+
'X-Citations': Buffer.from(JSON.stringify(citations)).toString('base64'),
|
|
214
|
+
},
|
|
215
|
+
})
|
|
261
216
|
} catch (err) {
|
|
262
217
|
console.error('Chat error:', err)
|
|
263
|
-
return
|
|
218
|
+
return new Response(JSON.stringify({ error: 'Internal server error' }), { status: 500 })
|
|
264
219
|
}
|
|
265
220
|
}
|
|
@@ -3,7 +3,60 @@ import { readFile, readdir } from 'fs/promises'
|
|
|
3
3
|
import { join } from 'path'
|
|
4
4
|
import { compileMDX } from 'next-mdx-remote/rsc'
|
|
5
5
|
import remarkGfm from 'remark-gfm'
|
|
6
|
-
import * as
|
|
6
|
+
import * as mdxComponents from '@/components/mdx'
|
|
7
|
+
import { findPageInNavigation, type Navigation } from '@/lib/navigation'
|
|
8
|
+
import { Children, isValidElement, type ReactNode } from 'react'
|
|
9
|
+
|
|
10
|
+
/** Wrapper for <pre> that routes mermaid code blocks to the Mermaid component */
|
|
11
|
+
function PreWithMermaid(props: { children: ReactNode; className?: string }) {
|
|
12
|
+
let isMermaid = false
|
|
13
|
+
let codeText = ''
|
|
14
|
+
|
|
15
|
+
Children.forEach(props.children, (child) => {
|
|
16
|
+
if (isValidElement(child) && child.type === 'code') {
|
|
17
|
+
const childProps = child.props as { className?: string; children?: string }
|
|
18
|
+
if (childProps.className?.includes('language-mermaid')) {
|
|
19
|
+
isMermaid = true
|
|
20
|
+
codeText = childProps.children || ''
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
if (isMermaid) {
|
|
26
|
+
return <mdxComponents.Mermaid chart={codeText} />
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return <mdxComponents.HighlightedCode {...props} />
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Passthrough component for generated content that references unknown JSX tags */
|
|
33
|
+
function Passthrough({ children }: { children?: ReactNode }) {
|
|
34
|
+
return <>{children}</>
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Strip empty <a id="..."></a> anchor tags — headings already have IDs from the Heading component */
|
|
38
|
+
function AnchorOrLink(props: { id?: string; href?: string; children?: ReactNode; className?: string }) {
|
|
39
|
+
// Empty anchor-only elements (e.g. <a id="pluginmanager"></a>) — strip them
|
|
40
|
+
if (props.id && !props.href && !props.children) {
|
|
41
|
+
return null
|
|
42
|
+
}
|
|
43
|
+
// Regular links — render normally
|
|
44
|
+
return <a {...props} />
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const components = {
|
|
48
|
+
...mdxComponents,
|
|
49
|
+
pre: PreWithMermaid,
|
|
50
|
+
h1: mdxComponents.H1,
|
|
51
|
+
h2: mdxComponents.H2,
|
|
52
|
+
h3: mdxComponents.H3,
|
|
53
|
+
h4: mdxComponents.H4,
|
|
54
|
+
a: AnchorOrLink,
|
|
55
|
+
// Passthrough for JSX tags that appear in generated code examples
|
|
56
|
+
Tabs: Passthrough,
|
|
57
|
+
TabItem: Passthrough,
|
|
58
|
+
Details: Passthrough,
|
|
59
|
+
}
|
|
7
60
|
import type { Metadata } from 'next'
|
|
8
61
|
|
|
9
62
|
interface PageProps {
|
|
@@ -23,6 +76,13 @@ function getDocsConfig() {
|
|
|
23
76
|
|
|
24
77
|
async function getContent(slug: string[]) {
|
|
25
78
|
const contentDir = join(process.cwd(), 'content', 'docs')
|
|
79
|
+
|
|
80
|
+
// Skip metadata files (llms.txt, llms-full.md, _navigation.json, _sidebars.json, README)
|
|
81
|
+
const lastName = slug[slug.length - 1]
|
|
82
|
+
if (lastName.startsWith('_') || lastName.startsWith('llms') || lastName === 'README') {
|
|
83
|
+
return null
|
|
84
|
+
}
|
|
85
|
+
|
|
26
86
|
const filePath = join(contentDir, ...slug)
|
|
27
87
|
|
|
28
88
|
// Try .mdx first, then .md
|
|
@@ -72,17 +132,11 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
|
|
|
72
132
|
|
|
73
133
|
const frontmatter = await getFrontmatter(slug)
|
|
74
134
|
|
|
75
|
-
// Fall back to nav config title
|
|
135
|
+
// Fall back to nav config title (works with both flat and tabbed navigation)
|
|
76
136
|
if (!frontmatter.title) {
|
|
77
137
|
const path = '/docs/' + slug.join('/')
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
if (page.path === path) {
|
|
81
|
-
frontmatter.title = page.title
|
|
82
|
-
break
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
}
|
|
138
|
+
const pageInfo = findPageInNavigation(docsConfig.navigation as Navigation || [], path)
|
|
139
|
+
if (pageInfo.title) frontmatter.title = pageInfo.title
|
|
86
140
|
}
|
|
87
141
|
|
|
88
142
|
const title = frontmatter.title || slug[slug.length - 1].replace(/-/g, ' ').replace(/\b\w/g, (c: string) => c.toUpperCase())
|
|
@@ -93,18 +147,12 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
|
|
|
93
147
|
}
|
|
94
148
|
}
|
|
95
149
|
|
|
96
|
-
/** Look up page title from docs.json navigation by pathname */
|
|
150
|
+
/** Look up page title from docs.json navigation by pathname (works with both formats) */
|
|
97
151
|
function getNavTitle(slug: string[]): string | undefined {
|
|
98
152
|
const docsConfig = getDocsConfig()
|
|
99
153
|
const path = '/docs/' + slug.join('/')
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
if (page.path === path) {
|
|
103
|
-
return page.title
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
return undefined
|
|
154
|
+
const pageInfo = findPageInNavigation(docsConfig.navigation as Navigation || [], path)
|
|
155
|
+
return pageInfo.title
|
|
108
156
|
}
|
|
109
157
|
|
|
110
158
|
export default async function DocPage({ params }: PageProps) {
|
|
@@ -121,8 +169,11 @@ export default async function DocPage({ params }: PageProps) {
|
|
|
121
169
|
const pageDescription = frontmatter.description
|
|
122
170
|
|
|
123
171
|
try {
|
|
172
|
+
// Strip empty <a id="..."></a> anchors from generated content — headings already have IDs
|
|
173
|
+
const cleanedSource = result.content.replace(/<a\s+id="[^"]*"\s*><\/a>\s*/g, '')
|
|
174
|
+
|
|
124
175
|
const { content } = await compileMDX({
|
|
125
|
-
source:
|
|
176
|
+
source: cleanedSource,
|
|
126
177
|
components: components as any,
|
|
127
178
|
options: {
|
|
128
179
|
parseFrontmatter: true,
|
|
@@ -160,6 +211,8 @@ export default async function DocPage({ params }: PageProps) {
|
|
|
160
211
|
{pageDescription && (
|
|
161
212
|
<div data-page-description={pageDescription} className="hidden" />
|
|
162
213
|
)}
|
|
214
|
+
{/* Raw content for copy page button */}
|
|
215
|
+
<script id="raw-page-content" type="text/plain" data-content={Buffer.from(result.content).toString('base64')} />
|
|
163
216
|
<div className="prose max-w-none">
|
|
164
217
|
{content}
|
|
165
218
|
</div>
|
|
@@ -189,6 +242,8 @@ export async function generateStaticParams() {
|
|
|
189
242
|
await walkDir(join(dir, entry.name), [...prefix, entry.name])
|
|
190
243
|
} else if (entry.name.endsWith('.md') || entry.name.endsWith('.mdx')) {
|
|
191
244
|
const name = entry.name.replace(/\.(mdx?|md)$/, '')
|
|
245
|
+
// Skip metadata files
|
|
246
|
+
if (name.startsWith('_') || name.startsWith('llms') || name === 'README') continue
|
|
192
247
|
if (name === 'index') {
|
|
193
248
|
if (prefix.length > 0) {
|
|
194
249
|
slugs.push({ slug: prefix })
|