@brainfish-ai/devdoc 0.1.22 → 0.1.23

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brainfish-ai/devdoc",
3
- "version": "0.1.22",
3
+ "version": "0.1.23",
4
4
  "description": "Documentation framework for developers. Write docs in MDX, preview locally, deploy to Brainfish.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -7,7 +7,6 @@ import { DocsSidebar } from './sidebar'
7
7
  import { ApiPlayground } from './playground'
8
8
  import type { DebugContext } from './playground/response-viewer'
9
9
  import { RightSidebar } from './sidebar/right-sidebar'
10
- import { Introduction } from './content/introduction'
11
10
  import { RequestDetails } from './content/request-details'
12
11
  import { DocPage } from './content/doc-page'
13
12
  import { ChangelogPage } from './content/changelog-page'
@@ -241,6 +240,53 @@ function findRequestById(collection: BrainfishCollection, id: string): Brainfish
241
240
  return null
242
241
  }
243
242
 
243
+ // Helper to get the first endpoint from collection
244
+ function getFirstEndpoint(collection: BrainfishCollection): BrainfishRESTRequest | null {
245
+ // Check direct requests first
246
+ if (collection.requests.length > 0) {
247
+ return collection.requests[0]
248
+ }
249
+
250
+ // Check folders recursively
251
+ for (const folder of collection.folders) {
252
+ const firstInFolder = getFirstEndpoint(folder)
253
+ if (firstInFolder) return firstInFolder
254
+ }
255
+ return null
256
+ }
257
+
258
+ // Helper to check if a doc group belongs to a specific tab
259
+ // Group IDs are formatted as: group-{tab-id}-{group-name}
260
+ function isGroupForTab(groupId: string, tabId: string): boolean {
261
+ // Check if the group ID starts with "group-{tabId}-"
262
+ const prefix = `group-${tabId}-`
263
+ return groupId.startsWith(prefix)
264
+ }
265
+
266
+ // Helper to check if a tab has doc groups (MDX pages)
267
+ function hasDocGroupsForTab(
268
+ docGroups: BrainfishDocGroup[] | undefined,
269
+ tabId: string
270
+ ): boolean {
271
+ if (!docGroups || docGroups.length === 0) return false
272
+
273
+ // Check if any doc group belongs to this tab and has pages
274
+ return docGroups.some(g => isGroupForTab(g.id, tabId) && g.pages.length > 0)
275
+ }
276
+
277
+ // Helper to get the first page from doc groups for a tab
278
+ function getFirstDocPageForTab(
279
+ docGroups: BrainfishDocGroup[] | undefined,
280
+ tabId: string
281
+ ): string | null {
282
+ if (!docGroups || docGroups.length === 0) return null
283
+
284
+ const tabDocGroup = docGroups.find(g => isGroupForTab(g.id, tabId) && g.pages.length > 0)
285
+
286
+ return tabDocGroup?.pages[0]?.slug || null
287
+ }
288
+
289
+
244
290
  // API version
245
291
  interface ApiVersion {
246
292
  version: string
@@ -391,19 +437,21 @@ function DocsContent() {
391
437
  const navigateToHash = useCallback((collectionData: BrainfishCollection) => {
392
438
  const hash = window.location.hash.slice(1) // Remove #
393
439
 
394
- console.log('[Docs] Navigating to hash:', hash)
395
-
396
440
  if (!hash) {
397
- // No hash - show Introduction by default
398
- setSelectedRequest(null)
441
+ // No hash - auto-select first endpoint
399
442
  setSelectedDocSection(null)
400
443
  setSelectedDocPage(null)
444
+ const firstEndpoint = getFirstEndpoint(collectionData)
445
+ if (firstEndpoint) {
446
+ setSelectedRequest(firstEndpoint)
447
+ } else {
448
+ setSelectedRequest(null)
449
+ }
401
450
  return
402
451
  }
403
452
 
404
453
  // Notes mode is handled by ModeContext, just clear API selection
405
454
  if (hash === 'notes' || hash.startsWith('notes/')) {
406
- console.log('[Docs] Notes mode detected, clearing API selection')
407
455
  setSelectedRequest(null)
408
456
  setSelectedDocSection(null)
409
457
  setSelectedDocPage(null)
@@ -428,9 +476,7 @@ function DocsContent() {
428
476
  const actualId = id || (legacyType ? hash.replace(`${legacyType}/`, '') : null)
429
477
 
430
478
  if (actualType === 'endpoint' && actualId) {
431
- console.log('[Docs] Looking for endpoint:', actualId)
432
479
  const request = findRequestById(collectionData, actualId)
433
- console.log('[Docs] Found request:', request?.name || 'NOT FOUND')
434
480
  if (request) {
435
481
  setSelectedRequest(request)
436
482
  setSelectedDocSection(null)
@@ -598,18 +644,55 @@ function DocsContent() {
598
644
  // Reset mode to docs when switching tabs (exit sandbox/api-client mode)
599
645
  switchToDocs()
600
646
 
601
- if (tabId === 'api-reference') {
602
- // Switch to API Reference - clear doc page and show introduction (no endpoint selected)
603
- setSelectedDocPage(null)
604
- setSelectedRequest(null)
605
- setSelectedDocSection(null)
606
- updateUrlHash('', tabId)
607
- } else if (tabId === 'changelog') {
647
+ // Find the tab config to check its type
648
+ const tabConfig = collection?.navigationTabs?.find(t => t.id === tabId)
649
+ const isApiTab = tabConfig?.type === 'openapi' || tabConfig?.type === 'graphql' || tabId === 'api-reference'
650
+
651
+ if (tabId === 'changelog') {
608
652
  // Switch to Changelog tab
609
653
  setSelectedDocPage(null)
610
654
  setSelectedRequest(null)
611
655
  setSelectedDocSection(null)
612
656
  updateUrlHash('', tabId)
657
+ } else if (isApiTab) {
658
+ // API Reference or GraphQL tab
659
+ setSelectedDocSection(null)
660
+
661
+ // Check if there are doc groups for this tab (MDX pages)
662
+ const hasGroups = hasDocGroupsForTab(collection?.docGroups, tabId)
663
+
664
+ if (hasGroups) {
665
+ // Has doc groups - select the first page from groups
666
+ const firstPage = getFirstDocPageForTab(collection?.docGroups, tabId)
667
+ if (firstPage) {
668
+ setSelectedDocPage(firstPage)
669
+ setSelectedRequest(null)
670
+ updateUrlHash(`page/${firstPage}`, tabId)
671
+ switchToDocs()
672
+ } else {
673
+ // No pages in groups - auto-select first endpoint
674
+ setSelectedDocPage(null)
675
+ const firstEndpoint = collection ? getFirstEndpoint(collection) : null
676
+ if (firstEndpoint) {
677
+ setSelectedRequest(firstEndpoint)
678
+ updateUrlHash(`endpoint/${firstEndpoint.id}`, tabId)
679
+ } else {
680
+ setSelectedRequest(null)
681
+ updateUrlHash('', tabId)
682
+ }
683
+ }
684
+ } else {
685
+ // No doc groups - auto-select first endpoint
686
+ setSelectedDocPage(null)
687
+ const firstEndpoint = collection ? getFirstEndpoint(collection) : null
688
+ if (firstEndpoint) {
689
+ setSelectedRequest(firstEndpoint)
690
+ updateUrlHash(`endpoint/${firstEndpoint.id}`, tabId)
691
+ } else {
692
+ setSelectedRequest(null)
693
+ updateUrlHash('', tabId)
694
+ }
695
+ }
613
696
  } else {
614
697
  // Switch to a doc group tab - find and select the first page in that tab
615
698
  setSelectedRequest(null)
@@ -617,10 +700,7 @@ function DocsContent() {
617
700
 
618
701
  // Find the first doc group for this tab and select its first page
619
702
  if (collection?.docGroups) {
620
- const tabDocGroup = collection.docGroups.find(g => {
621
- const groupTabPart = g.id.replace('group-', '').split('-')[0]
622
- return groupTabPart === tabId
623
- })
703
+ const tabDocGroup = collection.docGroups.find(g => isGroupForTab(g.id, tabId))
624
704
 
625
705
  if (tabDocGroup && tabDocGroup.pages.length > 0) {
626
706
  const firstPage = tabDocGroup.pages[0]
@@ -733,8 +813,6 @@ function DocsContent() {
733
813
  // Also update shortcut icon and apple-touch-icon
734
814
  updateFaviconLink('shortcut icon', faviconPath)
735
815
  updateFaviconLink('apple-touch-icon', faviconPath)
736
-
737
- console.log('[Docs] Set favicon to:', faviconPath)
738
816
  }, [collection?.docsFavicon])
739
817
 
740
818
  useEffect(() => {
@@ -761,16 +839,6 @@ function DocsContent() {
761
839
  }
762
840
 
763
841
  const data = await response.json()
764
-
765
- console.log('[Docs] Collection data:', JSON.stringify({
766
- hasData: !!data,
767
- name: data?.name,
768
- requestsCount: data?.requests?.length || 0,
769
- foldersCount: data?.folders?.length || 0,
770
- apiVersions: data?.apiVersions?.length || 0,
771
- selectedVersion: data?.selectedApiVersion,
772
- }, null, 2))
773
-
774
842
  setCollection(data)
775
843
 
776
844
  // Set initial API version if not already set
@@ -793,24 +861,60 @@ function DocsContent() {
793
861
  // Use setTimeout to ensure state is set before navigation
794
862
  setTimeout(() => {
795
863
  const hash = window.location.hash.slice(1)
796
- console.log('[Docs] Initial hash:', hash)
797
864
 
798
865
  if (!hash) {
799
866
  // No hash - set URL to initial tab
800
- updateUrlHash('', initialTabId)
801
- setSelectedRequest(null)
802
867
  setSelectedDocSection(null)
803
868
 
804
- if (initialTabId === 'api-reference') {
805
- // API Reference tab - show introduction
806
- setSelectedDocPage(null)
869
+ // Find the tab config to check its type
870
+ const tabConfig = data.navigationTabs?.find((t: NavigationTab) => t.id === initialTabId)
871
+ const isApiTab = tabConfig?.type === 'openapi' || tabConfig?.type === 'graphql' || initialTabId === 'api-reference'
872
+
873
+ if (isApiTab) {
874
+ // API Reference or GraphQL tab
875
+ setSelectedDocSection(null)
876
+
877
+ // Check if there are doc groups for this tab (MDX pages)
878
+ const hasGroups = hasDocGroupsForTab(data.docGroups, initialTabId)
879
+
880
+ if (hasGroups) {
881
+ // Has doc groups - select the first page from groups
882
+ const firstPage = getFirstDocPageForTab(data.docGroups, initialTabId)
883
+ if (firstPage) {
884
+ setSelectedDocPage(firstPage)
885
+ setSelectedRequest(null)
886
+ updateUrlHash(`page/${firstPage}`, initialTabId)
887
+ } else {
888
+ // No pages in groups - auto-select first endpoint
889
+ setSelectedDocPage(null)
890
+ const firstEndpoint = getFirstEndpoint(data)
891
+ if (firstEndpoint) {
892
+ setSelectedRequest(firstEndpoint)
893
+ updateUrlHash(`endpoint/${firstEndpoint.id}`, initialTabId)
894
+ } else {
895
+ setSelectedRequest(null)
896
+ updateUrlHash('', initialTabId)
897
+ }
898
+ }
899
+ } else {
900
+ // No doc groups - auto-select first endpoint
901
+ setSelectedDocPage(null)
902
+ const firstEndpoint = getFirstEndpoint(data)
903
+ if (firstEndpoint) {
904
+ setSelectedRequest(firstEndpoint)
905
+ updateUrlHash(`endpoint/${firstEndpoint.id}`, initialTabId)
906
+ } else {
907
+ setSelectedRequest(null)
908
+ updateUrlHash('', initialTabId)
909
+ }
910
+ }
807
911
  switchToDocs()
808
912
  } else {
809
913
  // Doc group tab - select first page
810
- const tabDocGroup = data.docGroups?.find((g: BrainfishDocGroup) => {
811
- const groupTabPart = g.id.replace('group-', '').split('-')[0]
812
- return groupTabPart === initialTabId
813
- })
914
+ setSelectedRequest(null)
915
+ const tabDocGroup = data.docGroups?.find((g: BrainfishDocGroup) =>
916
+ isGroupForTab(g.id, initialTabId)
917
+ )
814
918
 
815
919
  if (tabDocGroup && tabDocGroup.pages.length > 0) {
816
920
  const firstPage = tabDocGroup.pages[0]
@@ -819,12 +923,12 @@ function DocsContent() {
819
923
  switchToDocs()
820
924
  } else {
821
925
  setSelectedDocPage(null)
926
+ updateUrlHash('', initialTabId)
822
927
  switchToDocs()
823
928
  }
824
929
  }
825
930
  } else if (hash === 'notes' || hash.startsWith('notes/')) {
826
931
  // Notes mode - handled by ModeContext, just clear API selection
827
- console.log('[Docs] Initial hash is notes mode')
828
932
  setSelectedRequest(null)
829
933
  setSelectedDocSection(null)
830
934
  } else {
@@ -846,14 +950,13 @@ function DocsContent() {
846
950
  const actualId = isLegacyFormat ? parts.slice(1).join('/') : hashId
847
951
 
848
952
  if (actualType === 'endpoint' && actualId) {
849
- console.log('[Docs] Looking for endpoint on load:', actualId)
850
953
  const request = findRequestById(data, actualId)
851
- console.log('[Docs] Found:', request?.name || 'NOT FOUND')
852
954
  if (request) {
853
955
  setSelectedRequest(request)
854
956
  setSelectedDocSection(null)
855
957
  setSelectedDocPage(null)
856
958
  switchToDocs()
959
+ return
857
960
  } else {
858
961
  setSelectedRequest(null)
859
962
  setSelectedDocSection(null)
@@ -878,9 +981,49 @@ function DocsContent() {
878
981
  }, 100)
879
982
  } else if (hashTab && !hashType) {
880
983
  // Just a tab, show its default content
881
- setSelectedRequest(null)
882
984
  setSelectedDocSection(null)
883
- setSelectedDocPage(null)
985
+
986
+ // Check if this is an API tab
987
+ const tabConfig = data.navigationTabs?.find((t: NavigationTab) => t.id === hashTab)
988
+ const isApiTab = tabConfig?.type === 'openapi' || tabConfig?.type === 'graphql' || hashTab === 'api-reference'
989
+
990
+ if (isApiTab) {
991
+ // Check if there are doc groups for this tab (MDX pages)
992
+ const hasGroups = hasDocGroupsForTab(data.docGroups, hashTab)
993
+
994
+ if (hasGroups) {
995
+ // Has doc groups - select the first page from groups
996
+ const firstPage = getFirstDocPageForTab(data.docGroups, hashTab)
997
+ if (firstPage) {
998
+ setSelectedDocPage(firstPage)
999
+ setSelectedRequest(null)
1000
+ updateUrlHash(`page/${firstPage}`, hashTab)
1001
+ } else {
1002
+ // No pages in groups - auto-select first endpoint
1003
+ setSelectedDocPage(null)
1004
+ const firstEndpoint = getFirstEndpoint(data)
1005
+ if (firstEndpoint) {
1006
+ setSelectedRequest(firstEndpoint)
1007
+ updateUrlHash(`endpoint/${firstEndpoint.id}`, hashTab)
1008
+ } else {
1009
+ setSelectedRequest(null)
1010
+ }
1011
+ }
1012
+ } else {
1013
+ // No doc groups - auto-select first endpoint
1014
+ setSelectedDocPage(null)
1015
+ const firstEndpoint = getFirstEndpoint(data)
1016
+ if (firstEndpoint) {
1017
+ setSelectedRequest(firstEndpoint)
1018
+ updateUrlHash(`endpoint/${firstEndpoint.id}`, hashTab)
1019
+ } else {
1020
+ setSelectedRequest(null)
1021
+ }
1022
+ }
1023
+ } else {
1024
+ setSelectedDocPage(null)
1025
+ setSelectedRequest(null)
1026
+ }
884
1027
  switchToDocs()
885
1028
  }
886
1029
  }
@@ -1170,17 +1313,13 @@ function DocsWithMode({
1170
1313
  // Find the active tab's config
1171
1314
  const activeTabConfig = collection.navigationTabs?.find(t => t.id === activeTab)
1172
1315
  const activeTabType = activeTabConfig?.type || 'docs'
1173
- // Use the tab name for page header
1174
- const activeTabTitle = activeTabConfig?.tab || 'Documentation'
1175
1316
 
1176
1317
 
1177
1318
  // Filter doc groups for sidebar based on active tab
1178
1319
  // Group IDs are like "group-guides-getting-started", tab IDs are like "guides"
1179
- const filteredDocGroups = collection.docGroups?.filter(g => {
1180
- // Extract the tab part from the group ID (e.g., "guides" from "group-guides-getting-started")
1181
- const groupTabPart = g.id.replace('group-', '').split('-')[0]
1182
- return groupTabPart === activeTab
1183
- }) || []
1320
+ // Filter doc groups for sidebar based on active tab
1321
+ // Group IDs are formatted as: group-{tab-id}-{group-name}
1322
+ const filteredDocGroups = collection.docGroups?.filter(g => isGroupForTab(g.id, activeTab)) || []
1184
1323
 
1185
1324
  // Show endpoints in sidebar only when OpenAPI tab is active
1186
1325
  const showEndpoints = activeTabType === 'openapi'
@@ -1386,7 +1525,12 @@ function DocsWithMode({
1386
1525
  ) : selectedDocPage ? (
1387
1526
  <DocPage slug={selectedDocPage} />
1388
1527
  ) : (
1389
- <Introduction collection={collection} />
1528
+ <div className="flex-1 flex items-center justify-center bg-background">
1529
+ <div className="text-center text-muted-foreground">
1530
+ <Book className="h-12 w-12 mx-auto mb-3 opacity-40" />
1531
+ <p className="text-sm">Select an endpoint from the sidebar</p>
1532
+ </div>
1533
+ </div>
1390
1534
  )}
1391
1535
  </div>
1392
1536
  </DocsNavigationProvider>
@@ -15,7 +15,6 @@ import { CollectionTree } from './collection-tree'
15
15
  import { SidebarSection } from './sidebar-section'
16
16
  import { SidebarItem, SlidingIndicatorProvider } from './sidebar-item'
17
17
  import { SidebarGroup } from './sidebar-group'
18
- import { extractMarkdownHeadings, type MarkdownHeading } from '@/lib/api-docs/utils'
19
18
  import { useMobile } from '@/lib/api-docs/mobile-context'
20
19
  import { cn } from '@/lib/utils'
21
20
 
@@ -84,92 +83,8 @@ export function DocsSidebar({
84
83
  }
85
84
  }
86
85
 
87
- // Extract documentation headings from collection description
88
- const docHeadings = useMemo(() => {
89
- return collection.description
90
- ? extractMarkdownHeadings(collection.description)
91
- : []
92
- }, [collection.description])
93
-
94
86
  // Get doc groups from collection
95
87
  const docGroups = collection.docGroups || []
96
-
97
- const handleDocClick = (headingId: string) => {
98
- handleDocClickWithClose(headingId)
99
- }
100
-
101
- // Determine if Introduction should be highlighted
102
- const isIntroductionSelected = !selectedRequest && !selectedDocSection && !selectedDocPage
103
-
104
- // Check if a heading or any of its children is selected
105
- const isHeadingSelected = (heading: MarkdownHeading): boolean => {
106
- if (selectedDocSection === heading.id) return true
107
- if (heading.children?.some(child => selectedDocSection === child.id)) return true
108
- return false
109
- }
110
-
111
- // Get the selected child heading ID for a heading group
112
- const getSelectedChildHeading = (heading: MarkdownHeading): string | null => {
113
- if (selectedDocSection === heading.id) return heading.id
114
- const selected = heading.children?.find(child => selectedDocSection === child.id)
115
- return selected?.id || null
116
- }
117
-
118
- // Render a documentation heading item (may have children)
119
- const renderHeading = (heading: MarkdownHeading, index: number) => {
120
- const hasChildren = heading.children && heading.children.length > 0
121
- const isSelected = selectedDocSection === heading.id
122
-
123
- // If heading is "Introduction", handle it specially
124
- if (heading.text === 'Introduction') {
125
- return (
126
- <SidebarItem
127
- key={`${heading.id}-${index}`}
128
- itemId={`heading-introduction`}
129
- selected={isIntroductionSelected}
130
- onClick={() => handleDocClick('introduction')}
131
- >
132
- Introduction
133
- </SidebarItem>
134
- )
135
- }
136
-
137
- if (hasChildren) {
138
- return (
139
- <SidebarGroup
140
- key={`${heading.id}-${index}`}
141
- title={heading.text}
142
- defaultOpen={isHeadingSelected(heading)}
143
- selected={isSelected}
144
- selectedChildSlug={getSelectedChildHeading(heading)}
145
- onClick={() => handleDocClick(heading.id)}
146
- >
147
- {heading.children!.map((child, childIndex) => (
148
- <SidebarItem
149
- key={`${child.id}-${childIndex}`}
150
- itemId={`heading-${child.id}`}
151
- selected={selectedDocSection === child.id}
152
- indent={1}
153
- onClick={() => handleDocClick(child.id)}
154
- >
155
- {child.text}
156
- </SidebarItem>
157
- ))}
158
- </SidebarGroup>
159
- )
160
- }
161
-
162
- return (
163
- <SidebarItem
164
- key={`${heading.id}-${index}`}
165
- itemId={`heading-${heading.id}`}
166
- selected={isSelected}
167
- onClick={() => handleDocClick(heading.id)}
168
- >
169
- {heading.text}
170
- </SidebarItem>
171
- )
172
- }
173
88
 
174
89
  // Check if any child in a group is selected
175
90
  const isChildSelected = (page: BrainfishDocPage): boolean => {
@@ -300,14 +215,7 @@ export function DocsSidebar({
300
215
 
301
216
  {/* Scrollable content with sliding indicator */}
302
217
  <SlidingIndicatorProvider className="docs-sidebar-content flex-1 overflow-y-auto overflow-x-hidden custom-scroll">
303
- {/* Documentation Section (from description headings) - Only shown in API Reference tab */}
304
- {activeTab === 'api-reference' && docHeadings.length > 0 && (
305
- <SidebarSection title="Documentation">
306
- {docHeadings.map((heading, index) => renderHeading(heading, index))}
307
- </SidebarSection>
308
- )}
309
-
310
- {/* Documentation Pages Section (from docs.json) */}
218
+ {/* Documentation Pages Section (from docs.json groups) */}
311
219
  {docGroups.length > 0 && (
312
220
  <>
313
221
  {docGroups.map((group, index) => (
@@ -315,7 +223,7 @@ export function DocsSidebar({
315
223
  key={group.id}
316
224
  title={group.title}
317
225
  icon={group.icon}
318
- className={index > 0 || (activeTab === 'api-reference' && docHeadings.length > 0) ? '' : ''}
226
+ className={index > 0 ? '' : ''}
319
227
  >
320
228
  {group.pages.map(renderDocPage)}
321
229
  </SidebarSection>
@@ -327,7 +235,7 @@ export function DocsSidebar({
327
235
  {(collection.folders.length > 0 || collection.requests.length > 0) && (
328
236
  <SidebarSection
329
237
  title="Endpoints"
330
- className={((activeTab === 'api-reference' && docHeadings.length > 0) || docGroups.length > 0) ? 'border-t border-sidebar-border' : ''}
238
+ className={docGroups.length > 0 ? 'border-t border-sidebar-border' : ''}
331
239
  >
332
240
  <CollectionTree
333
241
  collection={collection}
@@ -11,39 +11,61 @@ import type {
11
11
  } from './types'
12
12
 
13
13
  /**
14
- * Generates a unique ID for collections and requests
15
- * If a seed is provided, generates a deterministic hash-based ID
14
+ * Generates a URL-friendly slug from a string
15
+ * Converts to lowercase, replaces spaces and special chars with hyphens
16
16
  */
17
- function generateUniqueId(prefix: string, seed?: string): string {
18
- if (seed) {
19
- // Generate deterministic ID from seed using simple hash
20
- let hash = 0
21
- for (let i = 0; i < seed.length; i++) {
22
- const char = seed.charCodeAt(i)
23
- hash = ((hash << 5) - hash) + char
24
- hash = hash & hash // Convert to 32bit integer
25
- }
26
- // Convert to positive number and base36
27
- const hashStr = Math.abs(hash).toString(36)
28
- return `${prefix}_${hashStr}`
17
+ function slugify(str: string): string {
18
+ return str
19
+ .toLowerCase()
20
+ .replace(/[{}]/g, '') // Remove braces from path params
21
+ .replace(/[^a-z0-9]+/g, '-') // Replace non-alphanumeric with hyphens
22
+ .replace(/^-+|-+$/g, '') // Remove leading/trailing hyphens
23
+ .replace(/-+/g, '-') // Collapse multiple hyphens
24
+ }
25
+
26
+ /**
27
+ * Generates a human-readable, deterministic ID for requests
28
+ * Uses operationId if available, otherwise method + path
29
+ */
30
+ function generateRequestId(method: string, path: string, operationId?: string): string {
31
+ if (operationId) {
32
+ // Use operationId directly (it's already unique per spec)
33
+ return slugify(operationId)
34
+ }
35
+
36
+ // Generate from method + path: "get-users-id" for "GET /users/{id}"
37
+ const pathSlug = slugify(path)
38
+ return `${method.toLowerCase()}-${pathSlug}`
39
+ }
40
+
41
+ /**
42
+ * Generates a unique ID for collections
43
+ * If a seed is provided, generates a deterministic slug-based ID
44
+ */
45
+ function generateCollectionId(name?: string): string {
46
+ if (name) {
47
+ return `coll-${slugify(name)}`
29
48
  }
30
- return `${prefix}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
49
+ return `coll_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
31
50
  }
32
51
 
33
52
  /**
34
53
  * Creates a BrainfishRESTRequest with defaults
35
- * If method and endpoint are provided, generates a deterministic ID
54
+ * Generates human-readable, deterministic IDs based on operationId or method+path
36
55
  */
37
56
  export function makeBrainfishRESTRequest(
38
- request: Omit<BrainfishRESTRequest, 'id'>
57
+ request: Omit<BrainfishRESTRequest, 'id'> & { operationId?: string }
39
58
  ): BrainfishRESTRequest {
40
- // Generate deterministic ID from method + endpoint
41
- const idSeed = request.method && request.endpoint
42
- ? `${request.method}:${request.endpoint}`
43
- : undefined
59
+ // Extract path from endpoint (remove base URL)
60
+ const path = request.endpoint
61
+ ? request.endpoint.replace(/^https?:\/\/[^/]+/, '') || '/'
62
+ : '/'
63
+
64
+ // Generate human-readable ID
65
+ const id = generateRequestId(request.method || 'GET', path, request.operationId)
44
66
 
45
67
  return {
46
- id: generateUniqueId('req', idSeed),
68
+ id,
47
69
  name: request.name || 'Untitled Request',
48
70
  description: request.description ?? null,
49
71
  method: request.method || 'GET',
@@ -65,11 +87,8 @@ export function makeBrainfishRESTRequest(
65
87
  export function makeBrainfishCollection(
66
88
  collection: Omit<BrainfishCollection, 'id'>
67
89
  ): BrainfishCollection {
68
- // Generate deterministic ID from collection name
69
- const idSeed = collection.name ? `collection:${collection.name}` : undefined
70
-
71
90
  return {
72
- id: generateUniqueId('coll', idSeed),
91
+ id: generateCollectionId(collection.name),
73
92
  name: collection.name || 'Untitled Collection',
74
93
  description: collection.description ?? null,
75
94
  folders: collection.folders || [],
@@ -238,7 +238,7 @@ function extractOperations(
238
238
  const fields = rootType.getFields()
239
239
 
240
240
  return Object.values(fields).map(field => ({
241
- id: `${operationType}-${field.name}`,
241
+ id: `${operationType}-${field.name}`.toLowerCase(),
242
242
  name: field.name,
243
243
  description: field.description || null,
244
244
  operationType,
@@ -178,6 +178,7 @@ function convertPathToBrainfishReqs(
178
178
  requestVariables,
179
179
  responses,
180
180
  tags: (info as any).tags ?? [],
181
+ operationId: (info as any).operationId ?? undefined,
181
182
  })
182
183
 
183
184
  return {
@@ -58,6 +58,16 @@ const openapiTabSchema = z.object({
58
58
  path: z.string().optional(),
59
59
  versions: z.array(openapiVersionSchema).optional(),
60
60
  spec: z.string().optional(),
61
+ groups: z.array(groupSchema).optional(), // Doc groups alongside API reference
62
+ })
63
+
64
+ const graphqlTabSchema = z.object({
65
+ tab: z.string(),
66
+ type: z.literal('graphql'),
67
+ path: z.string().optional(),
68
+ schema: z.string(), // GraphQL schema file path
69
+ endpoint: z.string().optional(), // GraphQL endpoint URL
70
+ groups: z.array(groupSchema).optional(), // Doc groups alongside API reference
61
71
  })
62
72
 
63
73
  const changelogTabSchema = z.object({
@@ -66,7 +76,7 @@ const changelogTabSchema = z.object({
66
76
  path: z.string().optional(),
67
77
  })
68
78
 
69
- const tabSchema = z.union([docsTabSchema, openapiTabSchema, changelogTabSchema])
79
+ const tabSchema = z.union([docsTabSchema, openapiTabSchema, graphqlTabSchema, changelogTabSchema])
70
80
 
71
81
  const navigationSchema = z.object({
72
82
  tabs: z.array(tabSchema).optional(),
@@ -1,21 +0,0 @@
1
- 'use client'
2
-
3
- import { MarkdownRenderer } from '../shared/markdown-renderer'
4
- import type { BrainfishCollection } from '@/lib/api-docs/types'
5
-
6
- interface IntroductionProps {
7
- collection: BrainfishCollection
8
- }
9
-
10
- export function Introduction({ collection }: IntroductionProps) {
11
- return (
12
- <div className="docs-introduction docs-content max-w-4xl mx-auto px-4 py-6 sm:px-8 sm:py-8">
13
- <h1 className="docs-content-title text-2xl sm:text-3xl font-bold mb-4 sm:mb-6 text-foreground">{collection.name}</h1>
14
- {collection.description && (
15
- <div className="docs-prose prose prose-sm max-w-none prose-headings:text-foreground prose-p:text-muted-foreground prose-strong:text-foreground prose-code:text-foreground prose-pre:bg-muted prose-code:bg-muted prose-code:px-1 prose-code:py-0.5 prose-code:rounded prose-pre:overflow-x-auto prose-table:w-full prose-th:text-left prose-th:p-3 prose-th:bg-muted prose-td:p-3 prose-td:border-b prose-td:border-border">
16
- <MarkdownRenderer content={collection.description} />
17
- </div>
18
- )}
19
- </div>
20
- )
21
- }