@brainfish-ai/devdoc 0.1.21 → 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.
@@ -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}
@@ -2,21 +2,7 @@
2
2
 
3
3
  import { ReactNode, useRef, useEffect, useLayoutEffect, useState, createContext, useContext } from 'react'
4
4
  import { cn } from '@/lib/utils'
5
- import * as PhosphorIcons from '@phosphor-icons/react'
6
-
7
- // Helper to get Phosphor icon component from name
8
- function getIcon(iconName?: string): React.ComponentType<{ className?: string; weight?: "thin" | "light" | "regular" | "bold" | "fill" | "duotone" }> | null {
9
- if (!iconName) return null
10
-
11
- // Convert kebab-case to PascalCase for Phosphor icons
12
- const pascalCase = iconName
13
- .split('-')
14
- .map(part => part.charAt(0).toUpperCase() + part.slice(1))
15
- .join('')
16
-
17
- const IconComponent = (PhosphorIcons as Record<string, unknown>)[pascalCase] as React.ComponentType<{ className?: string; weight?: "thin" | "light" | "regular" | "bold" | "fill" | "duotone" }> | undefined
18
- return IconComponent || null
19
- }
5
+ import { getPhosphorIcon } from '@/lib/utils/icons'
20
6
 
21
7
  // Context for the sliding indicator
22
8
  interface SlidingIndicatorContextType {
@@ -152,7 +138,7 @@ export function SidebarItem({
152
138
  const buttonRef = useRef<HTMLButtonElement>(null)
153
139
  const context = useContext(SlidingIndicatorContext)
154
140
  const id = itemId || String(children)
155
- const IconComponent = getIcon(icon)
141
+ const IconComponent = getPhosphorIcon(icon)
156
142
 
157
143
  // Register this item with the sliding indicator provider
158
144
  useEffect(() => {
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { ReactNode } from 'react'
4
4
  import { cn } from '@/lib/utils'
5
- import * as PhosphorIcons from '@phosphor-icons/react'
5
+ import { getPhosphorIcon } from '@/lib/utils/icons'
6
6
 
7
7
  interface SidebarSectionProps {
8
8
  title: string
@@ -12,25 +12,11 @@ interface SidebarSectionProps {
12
12
  icon?: string
13
13
  }
14
14
 
15
- // Map of common icon names to Phosphor components
16
- function getIcon(iconName?: string): React.ComponentType<{ className?: string; weight?: "thin" | "light" | "regular" | "bold" | "fill" | "duotone" }> | null {
17
- if (!iconName) return null
18
-
19
- // Convert kebab-case to PascalCase for Phosphor icons
20
- const pascalCase = iconName
21
- .split('-')
22
- .map(part => part.charAt(0).toUpperCase() + part.slice(1))
23
- .join('')
24
-
25
- const IconComponent = (PhosphorIcons as Record<string, unknown>)[pascalCase] as React.ComponentType<{ className?: string; weight?: "thin" | "light" | "regular" | "bold" | "fill" | "duotone" }> | undefined
26
- return IconComponent || null
27
- }
28
-
29
15
  /**
30
16
  * Sidebar Section component - groups items under a title with optional icon
31
17
  */
32
18
  export function SidebarSection({ title, children, className = '', icon }: SidebarSectionProps) {
33
- const IconComponent = getIcon(icon)
19
+ const IconComponent = getPhosphorIcon(icon)
34
20
 
35
21
  return (
36
22
  <div className={cn('docs-sidebar-section flex flex-col', className)}>
@@ -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(),
@@ -17,8 +17,19 @@ export const frontmatterSchema = z.object({
17
17
  icon: z.string().optional(),
18
18
 
19
19
  // Page behavior
20
+ // - 'default': Standard documentation page with max-width container
21
+ // - 'wide': Wide container for content that needs more space
22
+ // - 'custom': Full-width custom layout (no container, no header) - for landing pages
20
23
  mode: z.enum(['default', 'wide', 'custom']).optional(),
21
24
 
25
+ // Custom page options (only used when mode: 'custom')
26
+ // Hides the default page header (title & description)
27
+ hideHeader: z.boolean().optional(),
28
+ // Full-width layout without container constraints
29
+ fullWidth: z.boolean().optional(),
30
+ // Custom background color or gradient
31
+ background: z.string().optional(),
32
+
22
33
  // OpenAPI integration
23
34
  openapi: z.string().optional(),
24
35
  api: z.string().optional(),
@@ -0,0 +1,48 @@
1
+ import * as PhosphorIcons from '@phosphor-icons/react'
2
+
3
+ export type PhosphorIconWeight = 'thin' | 'light' | 'regular' | 'bold' | 'fill' | 'duotone'
4
+
5
+ export type PhosphorIconComponent = React.ComponentType<{
6
+ className?: string
7
+ weight?: PhosphorIconWeight
8
+ }>
9
+
10
+ /**
11
+ * Get a Phosphor icon component by its kebab-case name
12
+ *
13
+ * @param iconName - The icon name in kebab-case (e.g., "arrow-right", "github-logo")
14
+ * @returns The Phosphor icon component or null if not found
15
+ *
16
+ * @example
17
+ * const Icon = getPhosphorIcon('arrow-right')
18
+ * if (Icon) {
19
+ * return <Icon className="h-5 w-5" weight="bold" />
20
+ * }
21
+ */
22
+ export function getPhosphorIcon(iconName?: string): PhosphorIconComponent | null {
23
+ if (!iconName) return null
24
+
25
+ // Convert kebab-case to PascalCase for Phosphor icons
26
+ const pascalCase = iconName
27
+ .split('-')
28
+ .map(part => part.charAt(0).toUpperCase() + part.slice(1))
29
+ .join('')
30
+
31
+ const IconComponent = (PhosphorIcons as Record<string, unknown>)[pascalCase]
32
+
33
+ if (typeof IconComponent === 'function') {
34
+ return IconComponent as PhosphorIconComponent
35
+ }
36
+
37
+ return null
38
+ }
39
+
40
+ /**
41
+ * Check if a Phosphor icon exists by name
42
+ *
43
+ * @param iconName - The icon name in kebab-case
44
+ * @returns true if the icon exists
45
+ */
46
+ export function hasPhosphorIcon(iconName?: string): boolean {
47
+ return getPhosphorIcon(iconName) !== null
48
+ }
@@ -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
- }