@brainfish-ai/devdoc 0.1.48 → 0.1.49

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 (36) hide show
  1. package/dist/cli/commands/deploy.js +16 -11
  2. package/package.json +1 -1
  3. package/renderer/app/[...slug]/client.js +17 -0
  4. package/renderer/app/[...slug]/page.js +125 -0
  5. package/renderer/app/api/assets/[...path]/route.js +23 -4
  6. package/renderer/app/api/chat/route.js +188 -25
  7. package/renderer/app/api/collections/route.js +95 -2
  8. package/renderer/app/api/deploy/route.js +4 -0
  9. package/renderer/app/api/suggestions/route.js +98 -10
  10. package/renderer/app/globals.css +33 -0
  11. package/renderer/app/layout.js +83 -8
  12. package/renderer/components/docs/mdx/cards.js +16 -45
  13. package/renderer/components/docs/mdx/file-tree.js +102 -0
  14. package/renderer/components/docs/mdx/index.js +7 -0
  15. package/renderer/components/docs-viewer/agent/agent-chat.js +75 -11
  16. package/renderer/components/docs-viewer/agent/messages/assistant-message.js +67 -3
  17. package/renderer/components/docs-viewer/agent/messages/tool-call-display.js +49 -4
  18. package/renderer/components/docs-viewer/content/content-router.js +1 -1
  19. package/renderer/components/docs-viewer/content/doc-page.js +36 -28
  20. package/renderer/components/docs-viewer/index.js +223 -58
  21. package/renderer/components/docs-viewer/playground/graphql-playground.js +131 -33
  22. package/renderer/components/docs-viewer/shared/method-badge.js +11 -2
  23. package/renderer/components/docs-viewer/sidebar/collection-tree.js +44 -6
  24. package/renderer/components/docs-viewer/sidebar/index.js +2 -1
  25. package/renderer/components/docs-viewer/sidebar/right-sidebar.js +3 -1
  26. package/renderer/components/docs-viewer/sidebar/sidebar-item.js +5 -7
  27. package/renderer/hooks/use-route-state.js +44 -56
  28. package/renderer/lib/api-docs/agent/indexer.js +73 -12
  29. package/renderer/lib/api-docs/agent/use-suggestions.js +26 -16
  30. package/renderer/lib/api-docs/code-editor/mode-context.js +16 -18
  31. package/renderer/lib/api-docs/parsers/openapi/transformer.js +8 -1
  32. package/renderer/lib/cache/purge.js +98 -0
  33. package/renderer/lib/docs-link-utils.js +146 -0
  34. package/renderer/lib/docs-navigation-context.js +3 -2
  35. package/renderer/lib/docs-navigation.js +50 -41
  36. package/renderer/lib/rate-limit.js +203 -0
@@ -147,25 +147,84 @@
147
147
  }
148
148
  return results;
149
149
  }
150
+ /**
151
+ * Detect if a request is a GraphQL operation based on its tags
152
+ */ function isGraphQLOperation(tags) {
153
+ const graphqlTags = [
154
+ 'query',
155
+ 'mutation',
156
+ 'subscription',
157
+ 'queries',
158
+ 'mutations',
159
+ 'subscriptions'
160
+ ];
161
+ return tags.some((tag)=>graphqlTags.includes(tag.toLowerCase()));
162
+ }
163
+ /**
164
+ * Get the GraphQL operation type from tags
165
+ */ function getGraphQLOperationType(tags) {
166
+ const tagLower = tags.map((t)=>t.toLowerCase());
167
+ if (tagLower.includes('query') || tagLower.includes('queries')) return 'query';
168
+ if (tagLower.includes('mutation') || tagLower.includes('mutations')) return 'mutation';
169
+ if (tagLower.includes('subscription') || tagLower.includes('subscriptions')) return 'subscription';
170
+ return undefined;
171
+ }
172
+ /**
173
+ * Extract GraphQL variable names from the request body
174
+ */ function extractGraphQLVariables(body) {
175
+ if (!body) return [];
176
+ try {
177
+ const parsed = JSON.parse(body);
178
+ if (parsed.variables && typeof parsed.variables === 'object') {
179
+ return Object.keys(parsed.variables);
180
+ }
181
+ } catch {
182
+ // Not valid JSON, ignore
183
+ }
184
+ return [];
185
+ }
150
186
  /**
151
187
  * Builds an endpoint index for AI context
152
188
  */ export function buildEndpointIndex(collection) {
153
189
  const allRequests = extractRequests(collection);
154
- return allRequests.map(({ request, tags })=>({
190
+ return allRequests.map(({ request, tags })=>{
191
+ const allTags = [
192
+ ...new Set([
193
+ ...tags,
194
+ ...request.tags
195
+ ])
196
+ ].filter(Boolean);
197
+ const isGraphQL = isGraphQLOperation(allTags);
198
+ const operationType = isGraphQL ? getGraphQLOperationType(allTags) : undefined;
199
+ // For GraphQL operations, create a more meaningful path
200
+ let path = request.endpoint;
201
+ if (isGraphQL && operationType) {
202
+ // Create path like "query:getUser" or "mutation:createUser"
203
+ path = `${operationType}:${request.name}`;
204
+ }
205
+ // For GraphQL, extract variable names from the body
206
+ let parameters = request.params.map((p)=>p.key);
207
+ if (isGraphQL && request.body.body && typeof request.body.body === 'string') {
208
+ const graphqlVars = extractGraphQLVariables(request.body.body);
209
+ if (graphqlVars.length > 0) {
210
+ parameters = graphqlVars;
211
+ }
212
+ }
213
+ return {
155
214
  id: request.id,
156
215
  name: request.name,
157
- method: request.method,
158
- path: request.endpoint,
216
+ method: isGraphQL ? operationType?.toUpperCase() || 'GRAPHQL' : request.method,
217
+ path,
159
218
  description: request.description,
160
- parameters: request.params.map((p)=>p.key),
219
+ parameters,
161
220
  hasBody: request.body.body !== null,
162
- tags: [
163
- ...new Set([
164
- ...tags,
165
- ...request.tags
166
- ])
167
- ].filter(Boolean)
168
- }));
221
+ tags: allTags,
222
+ type: isGraphQL ? 'graphql' : 'rest',
223
+ ...operationType && {
224
+ operationType
225
+ }
226
+ };
227
+ });
169
228
  }
170
229
  /**
171
230
  * Formats the endpoint index as a string for the AI system prompt
@@ -249,6 +308,8 @@
249
308
  description: ep.description,
250
309
  parameters: ep.parameters,
251
310
  hasBody: ep.hasBody,
252
- tags: ep.tags
311
+ tags: ep.tags,
312
+ type: ep.type,
313
+ operationType: ep.operationType
253
314
  }));
254
315
  }
@@ -1,5 +1,5 @@
1
1
  'use client';
2
- import { useState, useEffect, useMemo } from 'react';
2
+ import { useState, useEffect, useMemo, useRef } from 'react';
3
3
  /**
4
4
  * Shared hook for fetching contextual suggestions
5
5
  * Used by both AgentChat and AgentPopupButton
@@ -8,19 +8,28 @@ import { useState, useEffect, useMemo } from 'react';
8
8
  */ export function useSuggestions({ currentEndpoint, endpointIndex = [], limit }) {
9
9
  const [suggestions, setSuggestions] = useState([]);
10
10
  const [isLoading, setIsLoading] = useState(false);
11
- // Stable key for request deduplication
12
- const requestKey = useMemo(()=>currentEndpoint?.id || `general:${endpointIndex.length}`, [
13
- currentEndpoint?.id,
14
- endpointIndex.length
11
+ // Use refs for values that shouldn't trigger re-fetches when they change
12
+ const endpointIndexRef = useRef(endpointIndex);
13
+ endpointIndexRef.current = endpointIndex;
14
+ const limitRef = useRef(limit);
15
+ limitRef.current = limit;
16
+ // Extract primitive values for stable dependencies
17
+ const currentEndpointId = currentEndpoint?.id;
18
+ const currentEndpointName = currentEndpoint?.name;
19
+ const endpointIndexLength = endpointIndex.length;
20
+ // Stable key for request deduplication - only based on primitives
21
+ const requestKey = useMemo(()=>currentEndpointId || `general:${endpointIndexLength}`, [
22
+ currentEndpointId,
23
+ endpointIndexLength
15
24
  ]);
16
- // Fallback suggestions
25
+ // Fallback suggestions - only depend on primitive values
17
26
  const fallbackSuggestions = useMemo(()=>{
18
- if (currentEndpoint) {
27
+ if (currentEndpointName) {
19
28
  return [
20
29
  {
21
30
  title: 'What does this',
22
31
  label: 'endpoint do?',
23
- prompt: `What does ${currentEndpoint.name} do?`
32
+ prompt: `What does ${currentEndpointName} do?`
24
33
  },
25
34
  {
26
35
  title: 'What parameters',
@@ -52,9 +61,10 @@ import { useState, useEffect, useMemo } from 'react';
52
61
  }
53
62
  ];
54
63
  }, [
55
- currentEndpoint
64
+ currentEndpointName
56
65
  ]);
57
66
  // Fetch suggestions from API (server handles KV caching)
67
+ // Only re-fetch when requestKey changes (based on endpoint ID or index length)
58
68
  useEffect(()=>{
59
69
  let cancelled = false;
60
70
  setIsLoading(true);
@@ -64,19 +74,21 @@ import { useState, useEffect, useMemo } from 'react';
64
74
  'Content-Type': 'application/json'
65
75
  },
66
76
  body: JSON.stringify({
67
- endpointIndex,
68
- currentEndpointId: currentEndpoint?.id
77
+ endpointIndex: endpointIndexRef.current,
78
+ currentEndpointId
69
79
  })
70
80
  }).then((res)=>res.json()).then((data)=>{
71
81
  if (cancelled) return;
72
82
  if (data.suggestions) {
73
83
  const allSuggestions = data.suggestions;
74
- setSuggestions(limit ? allSuggestions.slice(0, limit) : allSuggestions);
84
+ const currentLimit = limitRef.current;
85
+ setSuggestions(currentLimit ? allSuggestions.slice(0, currentLimit) : allSuggestions);
75
86
  }
76
87
  }).catch((err)=>{
77
88
  if (cancelled) return;
78
89
  console.warn('[useSuggestions] Failed to fetch suggestions:', err);
79
- setSuggestions(limit ? fallbackSuggestions.slice(0, limit) : fallbackSuggestions);
90
+ const currentLimit = limitRef.current;
91
+ setSuggestions(currentLimit ? fallbackSuggestions.slice(0, currentLimit) : fallbackSuggestions);
80
92
  }).finally(()=>{
81
93
  if (!cancelled) setIsLoading(false);
82
94
  });
@@ -85,9 +97,7 @@ import { useState, useEffect, useMemo } from 'react';
85
97
  };
86
98
  }, [
87
99
  requestKey,
88
- currentEndpoint,
89
- endpointIndex,
90
- limit,
100
+ currentEndpointId,
91
101
  fallbackSuggestions
92
102
  ]);
93
103
  return {
@@ -4,14 +4,14 @@ import React, { createContext, useContext, useState, useCallback, useEffect, use
4
4
  import { switchView } from '@/lib/docs-navigation';
5
5
  const ModeContext = /*#__PURE__*/ createContext(null);
6
6
  /**
7
- * Parse view mode from URL hash
8
- */ function parseViewFromHash() {
7
+ * Parse view mode from URL pathname
8
+ */ function parseViewFromPath() {
9
9
  if (typeof window === 'undefined') return 'docs';
10
- const hash = window.location.hash.slice(1);
11
- if (!hash) return 'docs';
10
+ const pathname = window.location.pathname;
11
+ if (!pathname || pathname === '/') return 'docs';
12
12
  // Check for view suffix at the end
13
- if (hash.endsWith('/playground')) return 'playground';
14
- if (hash.endsWith('/notes')) return 'notes';
13
+ if (pathname.endsWith('/playground')) return 'playground';
14
+ if (pathname.endsWith('/notes')) return 'notes';
15
15
  return 'docs';
16
16
  }
17
17
  /**
@@ -39,29 +39,27 @@ const ModeContext = /*#__PURE__*/ createContext(null);
39
39
  }
40
40
  }
41
41
  export function ModeProvider({ children, defaultMode = 'docs' }) {
42
- // Track current hash to derive mode
43
- const [hash, setHash] = useState(()=>typeof window !== 'undefined' ? window.location.hash : '');
44
- // Derive mode from URL hash
42
+ // Track current pathname to derive mode
43
+ const [pathname, setPathname] = useState(()=>typeof window !== 'undefined' ? window.location.pathname : '');
44
+ // Derive mode from URL pathname
45
45
  const mode = useMemo(()=>{
46
- const view = parseViewFromHash();
46
+ const view = parseViewFromPath();
47
47
  return viewToPlaygroundMode(view);
48
48
  }, [
49
- hash
49
+ pathname
50
50
  ]);
51
51
  // Notes-specific state (not URL-based yet)
52
52
  const [activeFilePath, setActiveFilePathState] = useState(null);
53
53
  const [streamingContent, setStreamingContent] = useState(null);
54
54
  const [notesRefreshTrigger, setNotesRefreshTrigger] = useState(0);
55
- // Listen for hash changes to update mode
55
+ // Listen for pathname changes to update mode
56
56
  useEffect(()=>{
57
- const handleHashChange = ()=>{
58
- setHash(window.location.hash);
57
+ const handlePathChange = ()=>{
58
+ setPathname(window.location.pathname);
59
59
  };
60
- window.addEventListener('hashchange', handleHashChange);
61
- window.addEventListener('popstate', handleHashChange);
60
+ window.addEventListener('popstate', handlePathChange);
62
61
  return ()=>{
63
- window.removeEventListener('hashchange', handleHashChange);
64
- window.removeEventListener('popstate', handleHashChange);
62
+ window.removeEventListener('popstate', handlePathChange);
65
63
  };
66
64
  }, []);
67
65
  // Wrapper for setActiveFilePath
@@ -158,7 +158,14 @@ import { parseOpenAPIAuth, extractSecurityHeaders, getDefaultAuth } from './extr
158
158
  requestsByTags[tag] = [];
159
159
  }
160
160
  // Clone request for each tag (request can belong to multiple tags)
161
- requestsByTags[tag].push(cloneDeep(request));
161
+ // Generate unique ID per tag to avoid multiple highlights when selecting
162
+ const clonedRequest = cloneDeep(request);
163
+ if (tags.length > 1) {
164
+ // Append tag slug to ID for uniqueness when endpoint appears in multiple tags
165
+ const tagSlug = tag.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
166
+ clonedRequest.id = `${request.id}--${tagSlug}`;
167
+ }
168
+ requestsByTags[tag].push(clonedRequest);
162
169
  }
163
170
  });
164
171
  // Create folders (collections) for each tag
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Vercel Edge Cache Purge Utilities
3
+ *
4
+ * Functions for invalidating CDN-cached content after deployments
5
+ */ /**
6
+ * Purge Vercel Edge cache for a project
7
+ * Uses Vercel's Purge Cache API to invalidate CDN-cached content
8
+ *
9
+ * @param slug - Project slug to purge cache for
10
+ * @returns Promise that resolves when purge is complete (or skipped)
11
+ */ export async function purgeProjectCache(slug) {
12
+ const token = process.env.VERCEL_API_TOKEN;
13
+ const teamId = process.env.VERCEL_TEAM_ID;
14
+ const projectId = process.env.VERCEL_PROJECT_ID;
15
+ if (!token) {
16
+ console.log('[Cache] Skipping purge: VERCEL_API_TOKEN not set');
17
+ return;
18
+ }
19
+ const tags = [
20
+ `project-${slug}`,
21
+ `assets-${slug}`
22
+ ];
23
+ try {
24
+ // Try tag-based purge first (more targeted)
25
+ const tagPurgeSuccess = await purgeByTags(tags, token, teamId);
26
+ if (tagPurgeSuccess) {
27
+ console.log('[Cache] Edge cache purged for tags:', tags);
28
+ return;
29
+ }
30
+ // Fallback to project-wide cache purge
31
+ if (projectId) {
32
+ const projectPurgeSuccess = await purgeProjectWide(projectId, token, teamId);
33
+ if (projectPurgeSuccess) {
34
+ console.log('[Cache] Project cache purged successfully');
35
+ return;
36
+ }
37
+ }
38
+ console.warn('[Cache] Cache purge failed - content may be stale for up to 5 minutes');
39
+ } catch (error) {
40
+ console.warn('[Cache] Purge error:', error);
41
+ }
42
+ }
43
+ /**
44
+ * Purge cache by tags using Vercel's tag-based invalidation
45
+ */ async function purgeByTags(tags, token, teamId) {
46
+ try {
47
+ const url = new URL('https://api.vercel.com/v1/cache');
48
+ if (teamId) {
49
+ url.searchParams.set('teamId', teamId);
50
+ }
51
+ const response = await fetch(url.toString(), {
52
+ method: 'POST',
53
+ headers: {
54
+ 'Authorization': `Bearer ${token}`,
55
+ 'Content-Type': 'application/json'
56
+ },
57
+ body: JSON.stringify({
58
+ tags
59
+ })
60
+ });
61
+ return response.ok;
62
+ } catch {
63
+ return false;
64
+ }
65
+ }
66
+ /**
67
+ * Purge entire project cache (fallback when tag purge fails)
68
+ */ async function purgeProjectWide(projectId, token, teamId) {
69
+ try {
70
+ const url = new URL(`https://api.vercel.com/v1/projects/${projectId}/cache`);
71
+ if (teamId) {
72
+ url.searchParams.set('teamId', teamId);
73
+ }
74
+ const response = await fetch(url.toString(), {
75
+ method: 'DELETE',
76
+ headers: {
77
+ 'Authorization': `Bearer ${token}`
78
+ }
79
+ });
80
+ return response.ok;
81
+ } catch {
82
+ return false;
83
+ }
84
+ }
85
+ /**
86
+ * Purge specific asset paths from cache
87
+ *
88
+ * @param slug - Project slug
89
+ * @param paths - Array of asset paths to purge
90
+ */ export async function purgeAssetPaths(slug, paths) {
91
+ const tags = paths.map((p)=>`assets-${slug}-${p.replace(/[^a-z0-9]/gi, '-')}`);
92
+ const token = process.env.VERCEL_API_TOKEN;
93
+ const teamId = process.env.VERCEL_TEAM_ID;
94
+ if (!token) {
95
+ return;
96
+ }
97
+ await purgeByTags(tags, token, teamId);
98
+ }
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Docs Link Utilities
3
+ *
4
+ * Shared utilities for resolving internal documentation links.
5
+ * Used by Card component, MdxLink, and other components that handle
6
+ * navigation within documentation.
7
+ */ /**
8
+ * Check if a URL is external (starts with http/https or //)
9
+ */ export function isExternalLink(href) {
10
+ return href.startsWith('http') || href.startsWith('//');
11
+ }
12
+ /**
13
+ * Check if a path segment matches a known tab ID
14
+ */ export function isTabId(segment, tabIds) {
15
+ if (!tabIds || tabIds.length === 0) return false;
16
+ return tabIds.includes(segment);
17
+ }
18
+ /**
19
+ * Parse a link href and determine its type and target
20
+ */ export function parseLink(href, context) {
21
+ // External links
22
+ if (isExternalLink(href)) {
23
+ return {
24
+ href,
25
+ isTabLink: false,
26
+ isPageLink: false,
27
+ isExternal: true
28
+ };
29
+ }
30
+ // Anchor links (same page)
31
+ if (href.startsWith('#')) {
32
+ return {
33
+ href,
34
+ isTabLink: false,
35
+ isPageLink: false,
36
+ isExternal: false
37
+ };
38
+ }
39
+ // Internal links starting with /
40
+ if (href.startsWith('/')) {
41
+ const pathSegment = href.slice(1) // Remove leading /
42
+ ;
43
+ const pathParts = pathSegment.split('/');
44
+ const firstPart = pathParts[0];
45
+ // Check if this is a direct tab link (e.g., /facebook-pages-api)
46
+ if (pathParts.length === 1 && isTabId(firstPart, context.tabIds)) {
47
+ return {
48
+ href,
49
+ isTabLink: true,
50
+ isPageLink: false,
51
+ isExternal: false,
52
+ targetTab: firstPart
53
+ };
54
+ }
55
+ // Check if path already has proper structure (/tab/page/slug or /tab/endpoint/id)
56
+ if (href.includes('/page/') || href.includes('/endpoint/') || href.includes('/section/')) {
57
+ return {
58
+ href,
59
+ isTabLink: false,
60
+ isPageLink: true,
61
+ isExternal: false
62
+ };
63
+ }
64
+ // Simple page path - needs to be resolved with active tab context
65
+ return {
66
+ href,
67
+ isTabLink: false,
68
+ isPageLink: true,
69
+ isExternal: false,
70
+ targetSlug: pathSegment
71
+ };
72
+ }
73
+ // Relative links (no leading /)
74
+ return {
75
+ href,
76
+ isTabLink: false,
77
+ isPageLink: true,
78
+ isExternal: false,
79
+ targetSlug: href
80
+ };
81
+ }
82
+ /**
83
+ * Resolve a link href to its final URL based on context
84
+ *
85
+ * - Tab links: /facebook-pages-api → /facebook-pages-api (unchanged)
86
+ * - Page links within tab: /quickstart → /documentation/page/quickstart
87
+ * - Already resolved: /tab/page/slug → /tab/page/slug (unchanged)
88
+ * - External: https://... → https://... (unchanged)
89
+ */ export function resolveLink(href, context) {
90
+ if (!href) return href;
91
+ const parsed = parseLink(href, context);
92
+ // External links and anchor links stay as-is
93
+ if (parsed.isExternal || href.startsWith('#')) {
94
+ return href;
95
+ }
96
+ // Tab links stay as-is
97
+ if (parsed.isTabLink) {
98
+ return href;
99
+ }
100
+ // Already resolved paths stay as-is
101
+ if (href.includes('/page/') || href.includes('/endpoint/') || href.includes('/section/')) {
102
+ return href;
103
+ }
104
+ // Simple page paths need to be prefixed with active tab
105
+ if (parsed.targetSlug && context.activeTab) {
106
+ return `/${context.activeTab}/page/${parsed.targetSlug}`;
107
+ }
108
+ return href;
109
+ }
110
+ export function getLinkAction(href, context) {
111
+ if (!href) {
112
+ return {
113
+ type: 'navigate',
114
+ href: ''
115
+ };
116
+ }
117
+ const parsed = parseLink(href, context);
118
+ if (parsed.isExternal) {
119
+ return {
120
+ type: 'external',
121
+ href
122
+ };
123
+ }
124
+ if (href.startsWith('#')) {
125
+ return {
126
+ type: 'anchor',
127
+ href
128
+ };
129
+ }
130
+ if (parsed.isTabLink && parsed.targetTab) {
131
+ return {
132
+ type: 'switchTab',
133
+ tabId: parsed.targetTab
134
+ };
135
+ }
136
+ if (parsed.targetSlug) {
137
+ return {
138
+ type: 'navigatePage',
139
+ slug: parsed.targetSlug
140
+ };
141
+ }
142
+ return {
143
+ type: 'navigate',
144
+ href: resolveLink(href, context)
145
+ };
146
+ }
@@ -2,7 +2,7 @@
2
2
  import { jsx as _jsx } from "react/jsx-runtime";
3
3
  import React, { createContext, useContext, useCallback } from 'react';
4
4
  const DocsNavigationContext = /*#__PURE__*/ createContext(null);
5
- export function DocsNavigationProvider({ children, onNavigateToPage, onNavigateToEndpoint, onSwitchTab, activeTab }) {
5
+ export function DocsNavigationProvider({ children, onNavigateToPage, onNavigateToEndpoint, onSwitchTab, activeTab, tabIds }) {
6
6
  const navigateToPage = useCallback((slug)=>{
7
7
  if (onNavigateToPage) {
8
8
  onNavigateToPage(slug);
@@ -30,7 +30,8 @@ export function DocsNavigationProvider({ children, onNavigateToPage, onNavigateT
30
30
  navigateToEndpoint,
31
31
  switchToTab,
32
32
  isApiDocsView: !!onNavigateToPage,
33
- activeTab
33
+ activeTab,
34
+ tabIds
34
35
  },
35
36
  children: children
36
37
  });