@brainfish-ai/devdoc 0.1.21 → 0.1.22

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.21",
3
+ "version": "0.1.22",
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",
@@ -34,8 +34,8 @@ const BRAINFISH_API_BASE_URL = process.env.BRAINFISH_API_BASE_URL || 'https://ap
34
34
  const BRAINFISH_CATALOG_ID = process.env.BRAINFISH_CATALOG_ID || 'your_catalog_id_here'
35
35
  const BRAINFISH_JWT_TOKEN = process.env.BRAINFISH_JWT_TOKEN || 'your_jwt_token_here'
36
36
 
37
- // Debug logging
38
- if (process.env.NODE_ENV === 'development') {
37
+ // Debug logging - only in verbose mode
38
+ if (process.env.DEVDOC_VERBOSE === 'true') {
39
39
  console.log('[Collections] Config:', {
40
40
  useLocalSpec: USE_LOCAL_SPEC,
41
41
  starterPath: STARTER_PATH,
@@ -219,12 +219,10 @@ function loadGraphQLSchema(schemaPath: string): string | null {
219
219
  : resolvePath(relativePath)
220
220
 
221
221
  if (!existsSync(fullPath)) {
222
- console.log('[Collections] GraphQL schema not found at:', fullPath)
223
222
  return null
224
223
  }
225
224
 
226
225
  const content = readFileSync(fullPath, 'utf-8')
227
- console.log('[Collections] Loaded GraphQL schema from:', fullPath)
228
226
  return content
229
227
  } catch (error) {
230
228
  console.error('[Collections] Error loading GraphQL schema:', error)
@@ -243,14 +241,12 @@ function loadLocalSpec(specPath?: string): OpenApiSpec | null {
243
241
  : resolvePath(relativePath)
244
242
 
245
243
  if (!existsSync(fullPath)) {
246
- console.log('[Collections] Local spec not found at:', fullPath)
247
244
  return null
248
245
  }
249
246
 
250
247
  const content = readFileSync(fullPath, 'utf-8')
251
248
  const spec = JSON.parse(content) as OpenApiSpec
252
249
 
253
- console.log('[Collections] Loaded local spec:', spec.info?.title, 'v' + spec.info?.version, 'from', fullPath)
254
250
  return spec
255
251
  } catch (error) {
256
252
  console.error('[Collections] Error loading local spec:', error)
@@ -264,7 +260,6 @@ function loadDocsConfig(): DocsConfig | null {
264
260
  const configPath = resolvePath('docs.json')
265
261
 
266
262
  if (!existsSync(configPath)) {
267
- console.log('[Collections] docs.json not found at:', configPath)
268
263
  return null
269
264
  }
270
265
 
@@ -282,7 +277,6 @@ function loadThemeConfig(): ThemeConfig | null {
282
277
  const configPath = resolvePath('theme.json')
283
278
 
284
279
  if (!existsSync(configPath)) {
285
- console.log('[Collections] theme.json not found at:', configPath)
286
280
  return null
287
281
  }
288
282
 
@@ -577,8 +571,6 @@ async function fetchRemoteSpec(): Promise<OpenApiSpec | null> {
577
571
  try {
578
572
  const url = `${BRAINFISH_API_BASE_URL}/catalogs.openapi-spec`
579
573
 
580
- console.log('[Collections] Fetching from:', url)
581
-
582
574
  const response = await fetch(url, {
583
575
  method: 'POST',
584
576
  headers: {
@@ -612,7 +604,6 @@ async function fetchRemoteSpec(): Promise<OpenApiSpec | null> {
612
604
 
613
605
  const fallbackSpec = await CacheUtils.get<OpenApiSpec>(FALLBACK_CACHE_KEY)
614
606
  if (fallbackSpec) {
615
- console.warn('[Collections] Using fallback cache')
616
607
  return fallbackSpec
617
608
  }
618
609
 
@@ -627,7 +618,6 @@ async function getOpenApiSpec(specPath?: string): Promise<OpenApiSpec | null> {
627
618
  if (localSpec) {
628
619
  return localSpec
629
620
  }
630
- console.log('[Collections] Local spec not available, falling back to remote')
631
621
  }
632
622
 
633
623
  return fetchRemoteSpec()
@@ -811,11 +801,9 @@ async function getOrGenerateSummary(collection: BrainfishCollection, specVersion
811
801
 
812
802
  const cachedSummary = await CacheUtils.get<string>(versionedCacheKey)
813
803
  if (cachedSummary) {
814
- console.log('[Collections] Using cached API summary for version:', specVersion)
815
804
  return cachedSummary
816
805
  }
817
806
 
818
- console.log('[Collections] Generating new API summary for version:', specVersion)
819
807
  const summary = generateAPISummary(collection)
820
808
  const formattedSummary = formatAPISummaryForPrompt(summary)
821
809
 
@@ -869,8 +857,6 @@ export async function GET(request: Request) {
869
857
  // Get OpenAPI spec (with optional path for versioned specs)
870
858
  const spec = await getOpenApiSpec(specPath)
871
859
 
872
- console.log('[Collections] Loaded', docGroups.length, 'doc groups,', navigationTabs.length, 'tabs,', changelogReleases.length, 'changelog releases,', apiVersions.length, 'API versions, selected:', selectedVersion)
873
-
874
860
  // Handle no spec available
875
861
  if (!spec) {
876
862
  return NextResponse.json(
@@ -101,6 +101,11 @@ export async function GET(request: NextRequest) {
101
101
  title: frontmatter.title || slug,
102
102
  description: frontmatter.description,
103
103
  icon: frontmatter.icon,
104
+ // Page mode and layout options
105
+ mode: frontmatter.mode || 'default',
106
+ hideHeader: frontmatter.hideHeader || false,
107
+ fullWidth: frontmatter.fullWidth || false,
108
+ background: frontmatter.background,
104
109
  },
105
110
  mdxSource,
106
111
  rawContent: content, // Include raw content for changelog version extraction
@@ -171,6 +176,11 @@ async function handleMultiTenantDocs(projectSlug: string, slug: string): Promise
171
176
  title: frontmatter.title || slug,
172
177
  description: frontmatter.description,
173
178
  icon: frontmatter.icon,
179
+ // Page mode and layout options
180
+ mode: frontmatter.mode || 'default',
181
+ hideHeader: frontmatter.hideHeader || false,
182
+ fullWidth: frontmatter.fullWidth || false,
183
+ background: frontmatter.background,
174
184
  },
175
185
  mdxSource,
176
186
  rawContent: content,
@@ -1,15 +1,23 @@
1
1
  import { NextRequest, NextResponse } from 'next/server'
2
2
  import { readFileSync, existsSync } from 'fs'
3
- import { join } from 'path'
3
+ import { join, isAbsolute } from 'path'
4
4
  import { getProjectContent } from '@/lib/storage/blob'
5
5
 
6
- // Get the docs directory
6
+ // Configuration - match collections route
7
+ const STARTER_PATH = process.env.STARTER_PATH || 'devdoc-docs'
8
+
9
+ // Get the docs directory - respects STARTER_PATH for local development
7
10
  function getDocsDir(): string {
8
11
  const projectSlug = process.env.BRAINFISH_PROJECT_SLUG
9
12
  if (projectSlug) {
10
13
  return join(process.cwd(), '.devdoc', projectSlug)
11
14
  }
12
- return join(process.cwd(), 'devdoc-docs')
15
+
16
+ // Use STARTER_PATH (can be absolute or relative)
17
+ if (isAbsolute(STARTER_PATH)) {
18
+ return STARTER_PATH
19
+ }
20
+ return join(process.cwd(), STARTER_PATH)
13
21
  }
14
22
 
15
23
  export async function GET(request: NextRequest) {
@@ -1100,4 +1100,92 @@ code[data-line-numbers] > [data-line]::before {
1100
1100
  /* Scroll margin for agent navigation */
1101
1101
  [id] {
1102
1102
  scroll-margin-top: 80px;
1103
+ }
1104
+
1105
+ /* ============================================
1106
+ Custom Mode Page Styles (Landing Pages)
1107
+ ============================================ */
1108
+
1109
+ /* Custom mode pages should fill the entire content area */
1110
+ .docs-page-custom {
1111
+ width: 100%;
1112
+ min-height: 100%;
1113
+ margin: 0;
1114
+ padding: 0;
1115
+ }
1116
+
1117
+ /* Ensure custom content children stretch to fill width */
1118
+ .docs-custom-content {
1119
+ width: 100%;
1120
+ margin: 0;
1121
+ padding: 0;
1122
+ }
1123
+
1124
+ /* All direct children of custom content should be full width */
1125
+ .docs-custom-content > * {
1126
+ width: 100%;
1127
+ margin-left: 0;
1128
+ margin-right: 0;
1129
+ }
1130
+
1131
+ /* Landing page components should not have prose constraints */
1132
+ .docs-custom-content .landing-hero,
1133
+ .docs-custom-content .landing-section {
1134
+ max-width: none;
1135
+ width: 100%;
1136
+ margin-left: 0;
1137
+ margin-right: 0;
1138
+ }
1139
+
1140
+ /* When viewing a custom mode page, the content area should have no background */
1141
+ .docs-content-area:has(.docs-page-custom) {
1142
+ background: transparent !important;
1143
+ padding: 0 !important;
1144
+ }
1145
+
1146
+ /* Ensure the main header doesn't shrink */
1147
+ .docs-main-header {
1148
+ flex-shrink: 0 !important;
1149
+ width: 100% !important;
1150
+ }
1151
+
1152
+ /* Ensure docs-main container maintains width and doesn't collapse */
1153
+ .docs-main {
1154
+ flex: 1 1 0% !important;
1155
+ min-width: 0 !important;
1156
+ width: 100% !important;
1157
+ }
1158
+
1159
+ /* Ensure docs-layout children don't cause layout shifts */
1160
+ .docs-layout {
1161
+ display: flex !important;
1162
+ }
1163
+
1164
+ .docs-layout > .docs-sidebar {
1165
+ flex-shrink: 0 !important;
1166
+ }
1167
+
1168
+ /* Prevent content area from affecting parent layout */
1169
+ .docs-content-area {
1170
+ flex: 1 1 auto !important;
1171
+ min-height: 0 !important;
1172
+ }
1173
+
1174
+ /* Remove any default margins from sections in custom pages */
1175
+ .docs-custom-content section {
1176
+ margin: 0;
1177
+ }
1178
+
1179
+ /* Ensure Hero sections fill entire width */
1180
+ .landing-hero {
1181
+ width: 100%;
1182
+ margin: 0;
1183
+ padding-left: 0;
1184
+ padding-right: 0;
1185
+ }
1186
+
1187
+ /* Section components fill entire width */
1188
+ .landing-section {
1189
+ width: 100%;
1190
+ margin: 0;
1103
1191
  }
@@ -100,6 +100,23 @@ export {
100
100
  UpdateItem,
101
101
  } from './changelog'
102
102
 
103
+ // Landing Page Components
104
+ export {
105
+ Hero,
106
+ Pre,
107
+ Tagline,
108
+ Headline,
109
+ Description,
110
+ CommandBox,
111
+ Section,
112
+ Center,
113
+ FeatureGrid,
114
+ FeatureItem,
115
+ ButtonLink,
116
+ Spacer,
117
+ Divider,
118
+ } from './landing'
119
+
103
120
  // Re-export types
104
121
  export type { Frontmatter } from '@/lib/docs/mdx/frontmatter'
105
122
 
@@ -123,6 +140,7 @@ import { Badge } from './badge'
123
140
  import { Mermaid } from './mermaid'
124
141
  import { PDF, Audio, Download } from './file-embeds'
125
142
  import { ChangelogEntry, ChangelogTitle, ChangelogDescription, ChangelogImage, BreakingChange, MoreUpdates, UpdateItem } from './changelog'
143
+ import { Hero, Pre, Tagline, Headline, Description, CommandBox, Section, Center, FeatureGrid, FeatureItem, ButtonLink, Spacer, Divider } from './landing'
126
144
 
127
145
  export const mdxComponents = {
128
146
  // Callouts
@@ -199,6 +217,21 @@ export const mdxComponents = {
199
217
  BreakingChange,
200
218
  MoreUpdates,
201
219
  UpdateItem,
220
+
221
+ // Landing Page
222
+ Hero,
223
+ Pre,
224
+ Tagline,
225
+ Headline,
226
+ Description,
227
+ CommandBox,
228
+ Section,
229
+ Center,
230
+ FeatureGrid,
231
+ FeatureItem,
232
+ ButtonLink,
233
+ Spacer,
234
+ Divider,
202
235
  }
203
236
 
204
237
  export default mdxComponents
@@ -0,0 +1,684 @@
1
+ 'use client'
2
+
3
+ import React, { useState } from 'react'
4
+ import { cn } from '@/lib/utils'
5
+ import {
6
+ Copy,
7
+ Check,
8
+ ArrowRight,
9
+ GithubLogo,
10
+ Terminal,
11
+ ArrowUpRight,
12
+ RocketLaunch,
13
+ } from '@phosphor-icons/react'
14
+ import { getPhosphorIcon, type PhosphorIconComponent } from '@/lib/utils/icons'
15
+
16
+ // Direct icon map for commonly used icons (fallback if dynamic import fails)
17
+ const iconMap: Record<string, PhosphorIconComponent> = {
18
+ 'arrow-right': ArrowRight,
19
+ 'github-logo': GithubLogo,
20
+ 'terminal': Terminal,
21
+ 'arrow-up-right': ArrowUpRight,
22
+ 'rocket-launch': RocketLaunch,
23
+ }
24
+
25
+ /**
26
+ * Landing Page Components for MDX Documentation
27
+ *
28
+ * These components enable creating custom landing pages similar to skills.sh
29
+ * with hero sections, command boxes, feature grids, and more.
30
+ */
31
+
32
+ /**
33
+ * Hero Section Component
34
+ *
35
+ * A full-width hero section for landing pages with optional
36
+ * logo/ASCII art, tagline, and description.
37
+ */
38
+ interface HeroProps {
39
+ children?: React.ReactNode
40
+ className?: string
41
+ /** Background variant */
42
+ variant?: 'default' | 'gradient' | 'dark' | 'light' | 'pattern'
43
+ /** Vertical alignment */
44
+ align?: 'top' | 'center' | 'bottom'
45
+ /** Minimum height */
46
+ minHeight?: 'sm' | 'md' | 'lg' | 'full'
47
+ }
48
+
49
+ export function Hero({
50
+ children,
51
+ className,
52
+ variant = 'default',
53
+ align = 'center',
54
+ minHeight = 'md'
55
+ }: HeroProps) {
56
+ const variantStyles = {
57
+ default: 'bg-background text-foreground',
58
+ gradient: 'bg-gradient-to-b from-background via-background to-muted/30 text-foreground',
59
+ dark: 'bg-zinc-950 text-white [&_.landing-tagline]:text-zinc-400 [&_.landing-headline]:text-white [&_.landing-description]:text-zinc-300 [&_.landing-button]:border-zinc-600',
60
+ light: 'bg-white text-zinc-900 [&_.landing-tagline]:text-zinc-500 [&_.landing-headline]:text-zinc-900 [&_.landing-description]:text-zinc-600 [&_.landing-button]:border-zinc-300',
61
+ pattern: 'bg-background bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-primary/10 via-background to-background text-foreground',
62
+ }
63
+
64
+ const alignStyles = {
65
+ top: 'items-start pt-12',
66
+ center: 'items-center',
67
+ bottom: 'items-end pb-12',
68
+ }
69
+
70
+ const heightStyles = {
71
+ sm: 'min-h-[40vh]',
72
+ md: 'min-h-[60vh]',
73
+ lg: 'min-h-[80vh]',
74
+ full: 'min-h-screen',
75
+ }
76
+
77
+ return (
78
+ <section
79
+ className={cn(
80
+ 'landing-hero w-full flex flex-col justify-center',
81
+ variantStyles[variant],
82
+ alignStyles[align],
83
+ heightStyles[minHeight],
84
+ className
85
+ )}
86
+ >
87
+ <div className="landing-hero-content w-full max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
88
+ {children}
89
+ </div>
90
+ </section>
91
+ )
92
+ }
93
+
94
+ /**
95
+ * Pre Component
96
+ *
97
+ * Pre-formatted text component for ASCII art, logos, and other
98
+ * monospace content that needs to preserve whitespace.
99
+ */
100
+ interface PreProps {
101
+ children: React.ReactNode
102
+ className?: string
103
+ /** Font size preset */
104
+ size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'
105
+ /** Text alignment */
106
+ align?: 'left' | 'center' | 'right'
107
+ /** Text color variant */
108
+ color?: 'default' | 'muted' | 'primary' | 'white'
109
+ /** Line height */
110
+ leading?: 'none' | 'tight' | 'normal' | 'relaxed'
111
+ /** Hide from screen readers (decorative content) */
112
+ decorative?: boolean
113
+ }
114
+
115
+ export function Pre({
116
+ children,
117
+ className,
118
+ size = 'md',
119
+ align = 'center',
120
+ color = 'default',
121
+ leading = 'tight',
122
+ decorative = false,
123
+ }: PreProps) {
124
+ const sizeStyles = {
125
+ xs: 'text-[0.5rem] sm:text-xs',
126
+ sm: 'text-xs sm:text-sm',
127
+ md: 'text-sm sm:text-base md:text-lg',
128
+ lg: 'text-base sm:text-lg md:text-xl lg:text-2xl',
129
+ xl: 'text-lg sm:text-xl md:text-2xl lg:text-3xl xl:text-4xl',
130
+ }
131
+
132
+ const alignStyles = {
133
+ left: 'text-left',
134
+ center: 'text-center mx-auto',
135
+ right: 'text-right ml-auto',
136
+ }
137
+
138
+ const colorStyles = {
139
+ default: 'text-foreground',
140
+ muted: 'text-muted-foreground',
141
+ primary: 'text-primary',
142
+ white: 'text-white',
143
+ }
144
+
145
+ const leadingStyles = {
146
+ none: 'leading-none',
147
+ tight: 'leading-tight',
148
+ normal: 'leading-normal',
149
+ relaxed: 'leading-relaxed',
150
+ }
151
+
152
+ return (
153
+ <pre
154
+ className={cn(
155
+ 'landing-pre font-mono whitespace-pre select-none overflow-x-auto',
156
+ sizeStyles[size],
157
+ alignStyles[align],
158
+ colorStyles[color],
159
+ leadingStyles[leading],
160
+ className
161
+ )}
162
+ aria-hidden={decorative}
163
+ >
164
+ {children}
165
+ </pre>
166
+ )
167
+ }
168
+
169
+ /**
170
+ * Tagline Component
171
+ *
172
+ * Displays a prominent tagline or subtitle.
173
+ */
174
+ interface TaglineProps {
175
+ children: React.ReactNode
176
+ className?: string
177
+ /** Visual style */
178
+ variant?: 'default' | 'muted' | 'accent'
179
+ /** Letter spacing */
180
+ tracking?: 'normal' | 'wide' | 'wider' | 'widest'
181
+ }
182
+
183
+ export function Tagline({
184
+ children,
185
+ className,
186
+ variant = 'default',
187
+ tracking = 'wider'
188
+ }: TaglineProps) {
189
+ const variantStyles = {
190
+ default: '', // Inherit from parent
191
+ muted: 'opacity-60',
192
+ accent: 'text-primary',
193
+ }
194
+
195
+ const trackingStyles = {
196
+ normal: 'tracking-normal',
197
+ wide: 'tracking-wide',
198
+ wider: 'tracking-wider',
199
+ widest: 'tracking-widest',
200
+ }
201
+
202
+ // Use div instead of p to avoid hydration errors when MDX wraps content in <p>
203
+ return (
204
+ <div
205
+ className={cn(
206
+ 'landing-tagline text-sm sm:text-base font-medium uppercase',
207
+ variantStyles[variant],
208
+ trackingStyles[tracking],
209
+ className
210
+ )}
211
+ >
212
+ {children}
213
+ </div>
214
+ )
215
+ }
216
+
217
+ /**
218
+ * Headline Component
219
+ *
220
+ * Large display text for hero headlines.
221
+ */
222
+ interface HeadlineProps {
223
+ children: React.ReactNode
224
+ className?: string
225
+ /** Size preset */
226
+ size?: 'md' | 'lg' | 'xl' | '2xl'
227
+ /** Font weight */
228
+ weight?: 'normal' | 'medium' | 'semibold' | 'bold'
229
+ }
230
+
231
+ export function Headline({
232
+ children,
233
+ className,
234
+ size = 'xl',
235
+ weight = 'normal'
236
+ }: HeadlineProps) {
237
+ const sizeStyles = {
238
+ md: 'text-xl sm:text-2xl md:text-3xl',
239
+ lg: 'text-2xl sm:text-3xl md:text-4xl',
240
+ xl: 'text-3xl sm:text-4xl md:text-5xl',
241
+ '2xl': 'text-4xl sm:text-5xl md:text-6xl',
242
+ }
243
+
244
+ const weightStyles = {
245
+ normal: 'font-normal',
246
+ medium: 'font-medium',
247
+ semibold: 'font-semibold',
248
+ bold: 'font-bold',
249
+ }
250
+
251
+ return (
252
+ <h1
253
+ className={cn(
254
+ 'landing-headline leading-tight',
255
+ sizeStyles[size],
256
+ weightStyles[weight],
257
+ className
258
+ )}
259
+ >
260
+ {children}
261
+ </h1>
262
+ )
263
+ }
264
+
265
+ /**
266
+ * Description Component
267
+ *
268
+ * Body text for hero descriptions.
269
+ */
270
+ interface DescriptionProps {
271
+ children: React.ReactNode
272
+ className?: string
273
+ /** Size preset */
274
+ size?: 'sm' | 'md' | 'lg'
275
+ /** Max width */
276
+ maxWidth?: 'sm' | 'md' | 'lg' | 'full'
277
+ }
278
+
279
+ export function Description({
280
+ children,
281
+ className,
282
+ size = 'lg',
283
+ maxWidth = 'lg'
284
+ }: DescriptionProps) {
285
+ const sizeStyles = {
286
+ sm: 'text-sm sm:text-base',
287
+ md: 'text-base sm:text-lg',
288
+ lg: 'text-lg sm:text-xl',
289
+ }
290
+
291
+ const maxWidthStyles = {
292
+ sm: 'max-w-md',
293
+ md: 'max-w-xl',
294
+ lg: 'max-w-2xl',
295
+ full: 'max-w-full',
296
+ }
297
+
298
+ // Use div instead of p to avoid hydration errors when MDX wraps content in <p>
299
+ return (
300
+ <div
301
+ className={cn(
302
+ 'landing-description opacity-80 mx-auto',
303
+ sizeStyles[size],
304
+ maxWidthStyles[maxWidth],
305
+ className
306
+ )}
307
+ >
308
+ {children}
309
+ </div>
310
+ )
311
+ }
312
+
313
+ /**
314
+ * Command Box Component
315
+ *
316
+ * A copyable command display with a dark background,
317
+ * similar to the skills.sh installation command box.
318
+ */
319
+ interface CommandBoxProps {
320
+ /** The command to display */
321
+ command: string
322
+ /** Optional prefix like $ or > */
323
+ prefix?: string
324
+ className?: string
325
+ /** Visual variant - 'default' is dark (terminal style), 'light' adapts to theme */
326
+ variant?: 'default' | 'minimal' | 'bordered' | 'light'
327
+ }
328
+
329
+ export function CommandBox({
330
+ command,
331
+ prefix = '$',
332
+ className,
333
+ variant = 'default'
334
+ }: CommandBoxProps) {
335
+ const [copied, setCopied] = useState(false)
336
+
337
+ const handleCopy = async () => {
338
+ try {
339
+ await navigator.clipboard.writeText(command)
340
+ setCopied(true)
341
+ setTimeout(() => setCopied(false), 2000)
342
+ } catch (err) {
343
+ console.error('Failed to copy:', err)
344
+ }
345
+ }
346
+
347
+ const variantStyles = {
348
+ default: 'bg-zinc-950 border border-zinc-800 shadow-lg',
349
+ minimal: 'bg-zinc-900',
350
+ bordered: 'bg-zinc-950 border-2 border-zinc-700',
351
+ light: 'bg-muted border border-border shadow-sm',
352
+ }
353
+
354
+ const textStyles = {
355
+ default: 'text-white',
356
+ minimal: 'text-white',
357
+ bordered: 'text-white',
358
+ light: 'text-foreground',
359
+ }
360
+
361
+ const prefixStyles = {
362
+ default: 'text-zinc-400',
363
+ minimal: 'text-zinc-400',
364
+ bordered: 'text-zinc-400',
365
+ light: 'text-muted-foreground',
366
+ }
367
+
368
+ const buttonStyles = {
369
+ default: 'text-zinc-300 hover:text-white hover:bg-zinc-700',
370
+ minimal: 'text-zinc-300 hover:text-white hover:bg-zinc-700',
371
+ bordered: 'text-zinc-300 hover:text-white hover:bg-zinc-700',
372
+ light: 'text-muted-foreground hover:text-foreground hover:bg-muted-foreground/10',
373
+ }
374
+
375
+ return (
376
+ <div
377
+ className={cn(
378
+ 'landing-command-box inline-flex items-center gap-3 rounded-lg px-4 py-3',
379
+ variantStyles[variant],
380
+ className
381
+ )}
382
+ >
383
+ <code className={cn('flex items-center gap-2 font-mono text-sm sm:text-base', textStyles[variant])}>
384
+ {prefix && (
385
+ <span className={cn('select-none', prefixStyles[variant])}>{prefix}</span>
386
+ )}
387
+ <span>{command}</span>
388
+ </code>
389
+ <button
390
+ type="button"
391
+ onClick={handleCopy}
392
+ className={cn(
393
+ 'p-1.5 rounded transition-colors',
394
+ 'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/50',
395
+ buttonStyles[variant]
396
+ )}
397
+ title="Copy command"
398
+ >
399
+ {copied ? (
400
+ <Check className="h-4 w-4 text-emerald-500" weight="bold" />
401
+ ) : (
402
+ <Copy className="h-4 w-4" />
403
+ )}
404
+ </button>
405
+ </div>
406
+ )
407
+ }
408
+
409
+ /**
410
+ * Section Component
411
+ *
412
+ * A full-width section wrapper for landing page content.
413
+ */
414
+ interface SectionProps {
415
+ children: React.ReactNode
416
+ className?: string
417
+ /** Section ID for anchor links */
418
+ id?: string
419
+ /** Background variant */
420
+ variant?: 'default' | 'muted' | 'dark' | 'light' | 'accent'
421
+ /** Padding preset */
422
+ padding?: 'none' | 'sm' | 'md' | 'lg' | 'xl'
423
+ }
424
+
425
+ export function Section({
426
+ children,
427
+ className,
428
+ id,
429
+ variant = 'default',
430
+ padding = 'lg'
431
+ }: SectionProps) {
432
+ const variantStyles = {
433
+ default: 'bg-background text-foreground',
434
+ muted: 'bg-muted/30 text-foreground',
435
+ dark: 'bg-zinc-950 text-white [&_.landing-tagline]:text-zinc-400 [&_.landing-headline]:text-white [&_.landing-description]:text-zinc-300 [&_.landing-feature-title]:text-white [&_.landing-feature-description]:text-zinc-400 [&_.landing-button]:border-zinc-600',
436
+ light: 'bg-white text-zinc-900 [&_.landing-tagline]:text-zinc-500 [&_.landing-headline]:text-zinc-900 [&_.landing-description]:text-zinc-600 [&_.landing-feature-title]:text-zinc-900 [&_.landing-feature-description]:text-zinc-600 [&_.landing-button]:border-zinc-300',
437
+ accent: 'bg-primary/5 text-foreground',
438
+ }
439
+
440
+ const paddingStyles = {
441
+ none: '',
442
+ sm: 'py-8 sm:py-12',
443
+ md: 'py-12 sm:py-16',
444
+ lg: 'py-16 sm:py-24',
445
+ xl: 'py-24 sm:py-32',
446
+ }
447
+
448
+ return (
449
+ <section
450
+ id={id}
451
+ className={cn(
452
+ 'landing-section w-full',
453
+ variantStyles[variant],
454
+ paddingStyles[padding],
455
+ className
456
+ )}
457
+ >
458
+ <div className="landing-section-content max-w-5xl mx-auto px-4 sm:px-6 lg:px-8">
459
+ {children}
460
+ </div>
461
+ </section>
462
+ )
463
+ }
464
+
465
+ /**
466
+ * Center Component
467
+ *
468
+ * Centers content horizontally and optionally vertically.
469
+ */
470
+ interface CenterProps {
471
+ children: React.ReactNode
472
+ className?: string
473
+ /** Stack children with gap */
474
+ gap?: 'none' | 'sm' | 'md' | 'lg'
475
+ }
476
+
477
+ export function Center({ children, className, gap = 'md' }: CenterProps) {
478
+ const gapStyles = {
479
+ none: '',
480
+ sm: 'space-y-2',
481
+ md: 'space-y-4',
482
+ lg: 'space-y-8',
483
+ }
484
+
485
+ return (
486
+ <div
487
+ className={cn(
488
+ 'landing-center flex flex-col items-center text-center',
489
+ gapStyles[gap],
490
+ className
491
+ )}
492
+ >
493
+ {children}
494
+ </div>
495
+ )
496
+ }
497
+
498
+ /**
499
+ * Feature Grid Component
500
+ *
501
+ * A responsive grid for displaying feature cards.
502
+ */
503
+ interface FeatureGridProps {
504
+ children: React.ReactNode
505
+ className?: string
506
+ /** Number of columns */
507
+ cols?: 2 | 3 | 4
508
+ }
509
+
510
+ export function FeatureGrid({ children, className, cols = 3 }: FeatureGridProps) {
511
+ const colStyles = {
512
+ 2: 'grid-cols-1 md:grid-cols-2',
513
+ 3: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3',
514
+ 4: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-4',
515
+ }
516
+
517
+ return (
518
+ <div
519
+ className={cn(
520
+ 'landing-feature-grid grid gap-6',
521
+ colStyles[cols],
522
+ className
523
+ )}
524
+ >
525
+ {children}
526
+ </div>
527
+ )
528
+ }
529
+
530
+ /**
531
+ * Feature Item Component
532
+ *
533
+ * Individual feature card with icon, title, and description.
534
+ */
535
+ interface FeatureItemProps {
536
+ children?: React.ReactNode
537
+ title?: string
538
+ icon?: React.ReactNode
539
+ className?: string
540
+ }
541
+
542
+ export function FeatureItem({ children, title, icon, className }: FeatureItemProps) {
543
+ return (
544
+ <div
545
+ className={cn(
546
+ 'landing-feature-item p-6 rounded-xl border border-border/50 bg-card/50',
547
+ 'hover:border-primary/30 hover:bg-card/80 transition-colors',
548
+ className
549
+ )}
550
+ >
551
+ {icon && (
552
+ <div className="landing-feature-icon mb-4 text-primary text-2xl">
553
+ {icon}
554
+ </div>
555
+ )}
556
+ {title && (
557
+ <h3 className="landing-feature-title text-lg font-semibold mb-2">
558
+ {title}
559
+ </h3>
560
+ )}
561
+ {children && (
562
+ <div className="landing-feature-description text-sm opacity-80">
563
+ {children}
564
+ </div>
565
+ )}
566
+ </div>
567
+ )
568
+ }
569
+
570
+ /**
571
+ * Button Link Component
572
+ *
573
+ * Styled button links for CTAs.
574
+ */
575
+ interface ButtonLinkProps {
576
+ href: string
577
+ children: React.ReactNode
578
+ className?: string
579
+ /** Visual variant */
580
+ variant?: 'primary' | 'secondary' | 'outline' | 'ghost'
581
+ /** Size preset */
582
+ size?: 'sm' | 'md' | 'lg'
583
+ /** Phosphor icon name (e.g., "arrow-right", "github-logo") */
584
+ icon?: string
585
+ /** Icon position */
586
+ iconPosition?: 'left' | 'right'
587
+ }
588
+
589
+
590
+ export function ButtonLink({
591
+ href,
592
+ children,
593
+ className,
594
+ variant = 'primary',
595
+ size = 'md',
596
+ icon,
597
+ iconPosition = 'right'
598
+ }: ButtonLinkProps) {
599
+ const variantStyles = {
600
+ primary: 'bg-primary text-primary-foreground hover:bg-primary/90',
601
+ secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
602
+ outline: 'border border-current/30 bg-transparent text-current hover:bg-current/10',
603
+ ghost: 'bg-transparent text-current hover:bg-current/10',
604
+ }
605
+
606
+ const sizeStyles = {
607
+ sm: 'px-3 py-1.5 text-sm',
608
+ md: 'px-4 py-2 text-base',
609
+ lg: 'px-6 py-3 text-lg',
610
+ }
611
+
612
+ const iconSizes = {
613
+ sm: 'h-4 w-4',
614
+ md: 'h-5 w-5',
615
+ lg: 'h-5 w-5',
616
+ }
617
+
618
+ // Try direct map first, then dynamic lookup
619
+ const IconComponent = icon ? (iconMap[icon] || getPhosphorIcon(icon)) : null
620
+
621
+ return (
622
+ <a
623
+ href={href}
624
+ className={cn(
625
+ 'landing-button inline-flex items-center justify-center gap-2 rounded-lg font-medium',
626
+ 'transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/50',
627
+ variantStyles[variant],
628
+ sizeStyles[size],
629
+ className
630
+ )}
631
+ >
632
+ {IconComponent && iconPosition === 'left' && (
633
+ <IconComponent className={iconSizes[size]} weight="bold" />
634
+ )}
635
+ {children}
636
+ {IconComponent && iconPosition === 'right' && (
637
+ <IconComponent className={iconSizes[size]} weight="bold" />
638
+ )}
639
+ </a>
640
+ )
641
+ }
642
+
643
+ /**
644
+ * Spacer Component
645
+ *
646
+ * Adds vertical spacing.
647
+ */
648
+ interface SpacerProps {
649
+ size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl'
650
+ className?: string
651
+ }
652
+
653
+ export function Spacer({ size = 'md', className }: SpacerProps) {
654
+ const sizeStyles = {
655
+ xs: 'h-2',
656
+ sm: 'h-4',
657
+ md: 'h-8',
658
+ lg: 'h-12',
659
+ xl: 'h-16',
660
+ '2xl': 'h-24',
661
+ }
662
+
663
+ return <div className={cn('landing-spacer', sizeStyles[size], className)} />
664
+ }
665
+
666
+ /**
667
+ * Divider Component
668
+ *
669
+ * Horizontal divider line.
670
+ */
671
+ interface DividerProps {
672
+ className?: string
673
+ variant?: 'solid' | 'dashed' | 'gradient'
674
+ }
675
+
676
+ export function Divider({ className, variant = 'solid' }: DividerProps) {
677
+ const variantStyles = {
678
+ solid: 'border-t border-border',
679
+ dashed: 'border-t border-dashed border-border',
680
+ gradient: 'h-px bg-gradient-to-r from-transparent via-border to-transparent border-none',
681
+ }
682
+
683
+ return <hr className={cn('landing-divider w-full my-8', variantStyles[variant], className)} />
684
+ }
@@ -120,6 +120,20 @@ import {
120
120
  PDF,
121
121
  Audio,
122
122
  Download,
123
+ // Landing Page Components
124
+ Hero,
125
+ Pre,
126
+ Tagline,
127
+ Headline,
128
+ Description,
129
+ CommandBox,
130
+ Section,
131
+ Center,
132
+ FeatureGrid,
133
+ FeatureItem,
134
+ ButtonLink,
135
+ Spacer,
136
+ Divider,
123
137
  } from '../../docs/mdx/index'
124
138
 
125
139
  // MDX components mapping
@@ -190,6 +204,21 @@ const mdxComponents = {
190
204
  Audio,
191
205
  Download,
192
206
 
207
+ // Landing Page
208
+ Hero,
209
+ Pre,
210
+ Tagline,
211
+ Headline,
212
+ Description,
213
+ CommandBox,
214
+ Section,
215
+ Center,
216
+ FeatureGrid,
217
+ FeatureItem,
218
+ ButtonLink,
219
+ Spacer,
220
+ Divider,
221
+
193
222
  // Links
194
223
  a: MdxLink,
195
224
  }
@@ -204,6 +233,11 @@ interface DocPageData {
204
233
  title: string
205
234
  description?: string
206
235
  icon?: string
236
+ // Page mode and layout options
237
+ mode?: 'default' | 'wide' | 'custom'
238
+ hideHeader?: boolean
239
+ fullWidth?: boolean
240
+ background?: string
207
241
  }
208
242
  mdxSource: MDXRemoteSerializeResult
209
243
  }
@@ -244,7 +278,7 @@ export function DocPage({ slug }: DocPageProps) {
244
278
 
245
279
  if (loading) {
246
280
  return (
247
- <div className="docs-page docs-page-loading max-w-4xl mx-auto px-4 py-6 sm:px-8 sm:py-8">
281
+ <div className="docs-page docs-page-loading w-full min-h-[200px]">
248
282
  <div className="docs-loading flex items-center justify-center py-12">
249
283
  <Spinner className="h-6 w-6 animate-spin text-muted-foreground" />
250
284
  </div>
@@ -254,7 +288,7 @@ export function DocPage({ slug }: DocPageProps) {
254
288
 
255
289
  if (error || !pageData) {
256
290
  return (
257
- <div className="docs-page docs-page-error max-w-4xl mx-auto px-4 py-6 sm:px-8 sm:py-8">
291
+ <div className="docs-page docs-page-error w-full min-h-[200px]">
258
292
  <div className="docs-error text-center py-12">
259
293
  <p className="text-destructive">{error || 'Page not found'}</p>
260
294
  </div>
@@ -262,19 +296,52 @@ export function DocPage({ slug }: DocPageProps) {
262
296
  )
263
297
  }
264
298
 
265
- return (
266
- <div ref={contentRef} className="docs-page docs-content max-w-4xl mx-auto px-4 py-6 sm:px-8 sm:py-8">
267
- {/* Page header */}
268
- <div className="docs-page-header mb-6">
269
- <h1 className="docs-content-title text-2xl sm:text-3xl font-bold mb-2 text-foreground">
270
- {pageData.frontmatter.title}
271
- </h1>
272
- {pageData.frontmatter.description && (
273
- <p className="docs-content-description text-lg text-muted-foreground">
274
- {pageData.frontmatter.description}
275
- </p>
276
- )}
299
+ const { mode = 'default', hideHeader, fullWidth, background } = pageData.frontmatter
300
+ const isCustomMode = mode === 'custom'
301
+ const isWideMode = mode === 'wide'
302
+ const showHeader = !hideHeader && !isCustomMode
303
+
304
+ // Custom mode: Full-width layout for landing pages
305
+ if (isCustomMode) {
306
+ return (
307
+ <div
308
+ ref={contentRef}
309
+ className="docs-page docs-page-custom docs-content w-full min-h-full"
310
+ style={{
311
+ background: background || 'var(--background)',
312
+ // Ensure the content fills the entire viewport width within the content area
313
+ marginLeft: 0,
314
+ marginRight: 0,
315
+ }}
316
+ >
317
+ {/* Custom pages render MDX content directly without container constraints */}
318
+ <div className="docs-custom-content [&>*]:w-full">
319
+ <MDXRemote {...pageData.mdxSource} components={mdxComponents} />
320
+ </div>
277
321
  </div>
322
+ )
323
+ }
324
+
325
+ // Default/Wide mode: Standard documentation layout
326
+ const containerClass = isWideMode
327
+ ? 'max-w-6xl' // Wide mode
328
+ : 'max-w-4xl' // Default mode
329
+
330
+ return (
331
+ <div ref={contentRef} className={`docs-page docs-content ${containerClass} mx-auto px-4 py-6 sm:px-8 sm:py-8`}>
332
+ {/* Page header - shown unless hideHeader is true */}
333
+ {showHeader && (
334
+ <div className="docs-page-header mb-6">
335
+ <h1 className="docs-content-title text-2xl sm:text-3xl font-bold mb-2 text-foreground">
336
+ {pageData.frontmatter.title}
337
+ </h1>
338
+ {pageData.frontmatter.description && (
339
+ <p className="docs-content-description text-lg text-muted-foreground">
340
+ {pageData.frontmatter.description}
341
+ </p>
342
+ )}
343
+ </div>
344
+ )}
278
345
 
279
346
  {/* Page content - MDX rendered with custom components */}
280
347
  <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">
@@ -1285,7 +1285,7 @@ function DocsWithMode({
1285
1285
  }, [collection?.defaultTheme, setTheme])
1286
1286
 
1287
1287
  return (
1288
- <>
1288
+ <div className="docs-viewer-root flex flex-col h-screen overflow-hidden">
1289
1289
  {/* Notice */}
1290
1290
  {collection.notice && (
1291
1291
  <Notice config={collection.notice} storageKey="docs-notice" />
@@ -1313,7 +1313,7 @@ function DocsWithMode({
1313
1313
  docsNavbar={collection.docsNavbar}
1314
1314
  />
1315
1315
 
1316
- <div className="docs-layout flex h-[calc(100vh-6rem)] overflow-hidden relative z-0">
1316
+ <div className="docs-layout flex flex-1 overflow-hidden relative z-0 min-h-0">
1317
1317
  {/* Left Sidebar - Hidden for changelog */}
1318
1318
  {!showChangelog && (
1319
1319
  <DocsSidebar
@@ -1344,9 +1344,9 @@ function DocsWithMode({
1344
1344
  )}
1345
1345
 
1346
1346
  {/* Center - Toggles between API Client/Playground and Notes */}
1347
- <div className="docs-main flex-1 flex flex-col overflow-hidden min-w-0">
1347
+ <div className="docs-main flex-1 flex flex-col overflow-hidden" style={{ minWidth: 0, flexBasis: 0, flexGrow: 1 }}>
1348
1348
  {/* Mode Toggle Header */}
1349
- <div className="docs-main-header flex items-center justify-end px-3 sm:px-4 h-[41px] border-b border-border bg-muted/30">
1349
+ <div className="docs-main-header flex items-center justify-end px-3 sm:px-4 h-[41px] border-b border-border bg-muted/30 flex-shrink-0">
1350
1350
  <ModeToggleTabs hasEndpoint={!!selectedRequest} />
1351
1351
  </div>
1352
1352
 
@@ -1446,7 +1446,7 @@ function DocsWithMode({
1446
1446
  open={showAuthModal}
1447
1447
  onClose={() => setShowAuthModal(false)}
1448
1448
  />
1449
- </>
1449
+ </div>
1450
1450
  )
1451
1451
  }
1452
1452
 
@@ -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)}>
@@ -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
+ }