@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 +1 -1
- package/renderer/app/api/collections/route.ts +2 -16
- package/renderer/app/api/docs/route.ts +10 -0
- package/renderer/app/api/schema/route.ts +11 -3
- package/renderer/app/globals.css +88 -0
- package/renderer/components/docs/mdx/index.ts +33 -0
- package/renderer/components/docs/mdx/landing.tsx +684 -0
- package/renderer/components/docs-viewer/content/doc-page.tsx +81 -14
- package/renderer/components/docs-viewer/index.tsx +5 -5
- package/renderer/components/docs-viewer/sidebar/sidebar-item.tsx +2 -16
- package/renderer/components/docs-viewer/sidebar/sidebar-section.tsx +2 -16
- package/renderer/lib/docs/mdx/frontmatter.ts +11 -0
- package/renderer/lib/utils/icons.ts +48 -0
package/package.json
CHANGED
|
@@ -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.
|
|
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
|
-
//
|
|
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
|
-
|
|
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) {
|
package/renderer/app/globals.css
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
+
}
|