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.
Files changed (120) hide show
  1. package/dist/auth/index.js +8 -1
  2. package/dist/autofix/index.d.ts +0 -4
  3. package/dist/autofix/index.js +0 -21
  4. package/dist/capture/browser.d.ts +11 -0
  5. package/dist/capture/browser.js +173 -0
  6. package/dist/capture/diff.d.ts +23 -0
  7. package/dist/capture/diff.js +52 -0
  8. package/dist/capture/index.d.ts +23 -0
  9. package/dist/capture/index.js +210 -0
  10. package/dist/capture/naming.d.ts +17 -0
  11. package/dist/capture/naming.js +45 -0
  12. package/dist/capture/parser.d.ts +15 -0
  13. package/dist/capture/parser.js +80 -0
  14. package/dist/capture/types.d.ts +57 -0
  15. package/dist/capture/types.js +1 -0
  16. package/dist/cli.js +4 -0
  17. package/dist/commands/autofix.js +136 -120
  18. package/dist/commands/cron.js +58 -47
  19. package/dist/commands/deploy.js +123 -102
  20. package/dist/commands/generate.js +88 -6
  21. package/dist/commands/heal.d.ts +10 -0
  22. package/dist/commands/heal.js +201 -0
  23. package/dist/commands/i18n.js +146 -111
  24. package/dist/commands/lint.js +50 -44
  25. package/dist/commands/llms-txt.js +59 -49
  26. package/dist/commands/login.js +61 -43
  27. package/dist/commands/mcp.js +6 -0
  28. package/dist/commands/monitor.js +13 -8
  29. package/dist/commands/qa.d.ts +2 -0
  30. package/dist/commands/qa.js +43 -0
  31. package/dist/commands/review-pr.js +108 -102
  32. package/dist/commands/sdk.js +128 -122
  33. package/dist/commands/security.js +86 -80
  34. package/dist/commands/test.js +91 -92
  35. package/dist/commands/version.js +104 -75
  36. package/dist/commands/watch.js +130 -114
  37. package/dist/config/types.js +2 -2
  38. package/dist/context-hub/index.d.ts +23 -0
  39. package/dist/context-hub/index.js +179 -0
  40. package/dist/context-hub/mappings.d.ts +8 -0
  41. package/dist/context-hub/mappings.js +55 -0
  42. package/dist/context-hub/types.d.ts +33 -0
  43. package/dist/context-hub/types.js +1 -0
  44. package/dist/generator/generator.js +39 -6
  45. package/dist/generator/types.d.ts +7 -0
  46. package/dist/generator/writer.d.ts +3 -1
  47. package/dist/generator/writer.js +24 -4
  48. package/dist/llm/anthropic-client.d.ts +1 -0
  49. package/dist/llm/anthropic-client.js +3 -1
  50. package/dist/llm/index.d.ts +6 -4
  51. package/dist/llm/index.js +76 -261
  52. package/dist/llm/openai-client.d.ts +1 -0
  53. package/dist/llm/openai-client.js +7 -2
  54. package/dist/qa/checks.d.ts +10 -0
  55. package/dist/qa/checks.js +492 -0
  56. package/dist/qa/fixes.d.ts +30 -0
  57. package/dist/qa/fixes.js +277 -0
  58. package/dist/qa/index.d.ts +29 -0
  59. package/dist/qa/index.js +187 -0
  60. package/dist/qa/types.d.ts +24 -0
  61. package/dist/qa/types.js +1 -0
  62. package/dist/scanner/csharp.d.ts +23 -0
  63. package/dist/scanner/csharp.js +421 -0
  64. package/dist/scanner/index.js +16 -2
  65. package/dist/scanner/java.d.ts +39 -0
  66. package/dist/scanner/java.js +318 -0
  67. package/dist/scanner/kotlin.d.ts +23 -0
  68. package/dist/scanner/kotlin.js +389 -0
  69. package/dist/scanner/php.d.ts +57 -0
  70. package/dist/scanner/php.js +351 -0
  71. package/dist/scanner/ruby.d.ts +36 -0
  72. package/dist/scanner/ruby.js +431 -0
  73. package/dist/scanner/swift.d.ts +25 -0
  74. package/dist/scanner/swift.js +392 -0
  75. package/dist/scanner/types.d.ts +1 -1
  76. package/dist/template/content/docs/_navigation.json +46 -0
  77. package/dist/template/content/docs/_sidebars.json +684 -0
  78. package/dist/template/content/docs/core.md +4544 -0
  79. package/dist/template/content/docs/index.mdx +89 -0
  80. package/dist/template/content/docs/integrations.md +1158 -0
  81. package/dist/template/content/docs/llms-full.md +403 -0
  82. package/dist/template/content/docs/llms.txt +4588 -0
  83. package/dist/template/content/docs/other.md +10379 -0
  84. package/dist/template/content/docs/tools.md +746 -0
  85. package/dist/template/content/docs/types.md +531 -0
  86. package/dist/template/docs.json +13 -11
  87. package/dist/template/mdx-components.tsx +27 -2
  88. package/dist/template/package.json +6 -0
  89. package/dist/template/public/search-index.json +1 -1
  90. package/dist/template/scripts/build-search-index.mjs +84 -6
  91. package/dist/template/src/app/api/chat/route.ts +83 -128
  92. package/dist/template/src/app/docs/[...slug]/page.tsx +75 -20
  93. package/dist/template/src/app/docs/llms-full.md +151 -4
  94. package/dist/template/src/app/docs/llms.txt +2464 -847
  95. package/dist/template/src/app/docs/page.mdx +48 -38
  96. package/dist/template/src/app/layout.tsx +3 -1
  97. package/dist/template/src/app/page.tsx +22 -8
  98. package/dist/template/src/components/ai-chat.tsx +73 -64
  99. package/dist/template/src/components/breadcrumbs.tsx +21 -23
  100. package/dist/template/src/components/copy-button.tsx +13 -9
  101. package/dist/template/src/components/copy-page-button.tsx +54 -0
  102. package/dist/template/src/components/docs-layout.tsx +37 -25
  103. package/dist/template/src/components/header.tsx +51 -10
  104. package/dist/template/src/components/mdx/card.tsx +17 -3
  105. package/dist/template/src/components/mdx/code-block.tsx +13 -9
  106. package/dist/template/src/components/mdx/code-group.tsx +13 -8
  107. package/dist/template/src/components/mdx/heading.tsx +15 -2
  108. package/dist/template/src/components/mdx/highlighted-code.tsx +13 -8
  109. package/dist/template/src/components/mdx/index.tsx +2 -0
  110. package/dist/template/src/components/mdx/mermaid.tsx +110 -0
  111. package/dist/template/src/components/mdx/screenshot.tsx +150 -0
  112. package/dist/template/src/components/scroll-to-hash.tsx +48 -0
  113. package/dist/template/src/components/sidebar.tsx +12 -18
  114. package/dist/template/src/components/table-of-contents.tsx +9 -0
  115. package/dist/template/src/lib/highlight.ts +3 -88
  116. package/dist/template/src/lib/navigation.ts +159 -0
  117. package/dist/template/src/styles/globals.css +17 -6
  118. package/dist/utils/validation.d.ts +0 -3
  119. package/dist/utils/validation.js +0 -26
  120. 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(/\/index$/, '')
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: data.section || data.category || '',
265
+ section: sectionLabel,
210
266
  })
211
267
 
212
- console.log(` Indexed: ${href} (content/)`)
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 { NextResponse } from 'next/server'
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
- interface Message {
6
- role: 'user' | 'assistant'
7
- content: string
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(), 'src/app/docs')
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, '') // Remove frontmatter
64
- .replace(/```[\s\S]*?```/g, '') // Remove code blocks
65
- .replace(/<[^>]+>/g, '') // Remove JSX/HTML
66
- .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // Convert links to text
67
- .slice(0, 2000) // Limit size
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) // Cap at 5 matches per word
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
- async function generateAnswer(
133
- query: string,
134
- context: DocChunk[],
135
- history: Message[]
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
- // Build context for AI
160
- const contextText = context
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: Message[] }
159
+ const { messages } = body as { messages: CoreMessage[] }
237
160
 
238
161
  if (!Array.isArray(messages) || messages.length === 0 || messages.length > 50) {
239
- return NextResponse.json({ error: 'Invalid request' }, { status: 400 })
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 NextResponse.json({ error: 'Message too long' }, { status: 400 })
167
+ return new Response(JSON.stringify({ error: 'Message too long' }), { status: 400 })
245
168
  }
246
169
 
247
- if (lastMessage.role !== 'user') {
248
- return NextResponse.json({ error: 'Last message must be from user' }, { status: 400 })
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
- // Index docs
252
- const chunks = indexDocs()
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
- // Search for relevant docs
255
- const relevantDocs = searchDocs(lastMessage.content, chunks)
202
+ Documentation context:
203
+ ${contextText}
256
204
 
257
- // Generate answer
258
- const answer = await generateAnswer(lastMessage.content, relevantDocs, messages.slice(0, -1))
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 NextResponse.json(answer)
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 NextResponse.json({ error: 'Internal server error' }, { status: 500 })
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 components from '@/components/mdx'
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
- for (const group of docsConfig.navigation || []) {
79
- for (const page of group.pages || []) {
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
- for (const group of docsConfig.navigation || []) {
101
- for (const page of group.pages || []) {
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: result.content,
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 })