@brainfish-ai/devdoc 0.1.30 → 0.1.31

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.
@@ -0,0 +1,122 @@
1
+ import { NextRequest, NextResponse } from 'next/server'
2
+ import path from 'path'
3
+ import fs from 'fs'
4
+
5
+ /**
6
+ * Local asset server for devdoc dev mode
7
+ *
8
+ * Serves assets from the user's project folder (STARTER_PATH/assets/)
9
+ * This allows /assets/images/logo.png to work in local development
10
+ */
11
+ export async function GET(
12
+ request: NextRequest,
13
+ { params }: { params: Promise<{ path: string[] }> }
14
+ ) {
15
+ try {
16
+ const { path: pathSegments } = await params
17
+
18
+ if (!pathSegments || pathSegments.length === 0) {
19
+ return NextResponse.json(
20
+ { error: 'Invalid asset path' },
21
+ { status: 400 }
22
+ )
23
+ }
24
+
25
+ // Get the project path from environment
26
+ const projectPath = process.env.STARTER_PATH
27
+
28
+ if (!projectPath) {
29
+ return NextResponse.json(
30
+ { error: 'Not in local development mode' },
31
+ { status: 404 }
32
+ )
33
+ }
34
+
35
+ // Build file path - assets are in projectPath/assets/
36
+ const assetPath = pathSegments.join('/')
37
+ const filePath = path.join(projectPath, 'assets', assetPath)
38
+
39
+ // Security: ensure we're not escaping the assets directory
40
+ const resolvedPath = path.resolve(filePath)
41
+ const assetsDir = path.resolve(path.join(projectPath, 'assets'))
42
+
43
+ if (!resolvedPath.startsWith(assetsDir)) {
44
+ return NextResponse.json(
45
+ { error: 'Invalid path' },
46
+ { status: 403 }
47
+ )
48
+ }
49
+
50
+ // Check if file exists
51
+ if (!fs.existsSync(resolvedPath)) {
52
+ // Also try without 'assets' prefix (for /assets/images/x.png -> images/x.png)
53
+ const altPath = path.join(projectPath, assetPath)
54
+ const resolvedAltPath = path.resolve(altPath)
55
+
56
+ if (resolvedAltPath.startsWith(path.resolve(projectPath)) && fs.existsSync(resolvedAltPath)) {
57
+ const fileBuffer = fs.readFileSync(resolvedAltPath)
58
+ const contentType = getContentType(assetPath)
59
+
60
+ return new NextResponse(fileBuffer, {
61
+ status: 200,
62
+ headers: {
63
+ 'Content-Type': contentType,
64
+ 'Cache-Control': 'no-cache',
65
+ },
66
+ })
67
+ }
68
+
69
+ return NextResponse.json(
70
+ { error: 'Asset not found', path: assetPath },
71
+ { status: 404 }
72
+ )
73
+ }
74
+
75
+ const fileBuffer = fs.readFileSync(resolvedPath)
76
+ const contentType = getContentType(assetPath)
77
+
78
+ return new NextResponse(fileBuffer, {
79
+ status: 200,
80
+ headers: {
81
+ 'Content-Type': contentType,
82
+ 'Cache-Control': 'no-cache', // No caching in dev
83
+ },
84
+ })
85
+
86
+ } catch (error) {
87
+ console.error('[Local Assets] Error:', error)
88
+ return NextResponse.json(
89
+ { error: 'Failed to serve asset' },
90
+ { status: 500 }
91
+ )
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Get content type from file extension
97
+ */
98
+ function getContentType(fileName: string): string {
99
+ const ext = fileName.split('.').pop()?.toLowerCase()
100
+ const types: Record<string, string> = {
101
+ 'jpg': 'image/jpeg',
102
+ 'jpeg': 'image/jpeg',
103
+ 'png': 'image/png',
104
+ 'gif': 'image/gif',
105
+ 'webp': 'image/webp',
106
+ 'svg': 'image/svg+xml',
107
+ 'ico': 'image/x-icon',
108
+ 'pdf': 'application/pdf',
109
+ 'mp4': 'video/mp4',
110
+ 'webm': 'video/webm',
111
+ 'mp3': 'audio/mpeg',
112
+ 'wav': 'audio/wav',
113
+ 'woff': 'font/woff',
114
+ 'woff2': 'font/woff2',
115
+ 'ttf': 'font/ttf',
116
+ 'otf': 'font/otf',
117
+ 'json': 'application/json',
118
+ 'css': 'text/css',
119
+ 'js': 'application/javascript',
120
+ }
121
+ return types[ext || ''] || 'application/octet-stream'
122
+ }
@@ -150,6 +150,15 @@
150
150
  @apply bg-background text-foreground;
151
151
  }
152
152
 
153
+ /* Prevent iOS zoom on input focus - ensure minimum 16px font size */
154
+ @media screen and (max-width: 768px) {
155
+ input,
156
+ select,
157
+ textarea {
158
+ font-size: 16px !important;
159
+ }
160
+ }
161
+
153
162
  /* Custom scrollbar - light and thin */
154
163
  * {
155
164
  scrollbar-width: thin;
@@ -1,4 +1,4 @@
1
- import type { Metadata } from "next";
1
+ import type { Metadata, Viewport } from "next";
2
2
  import { Geist, Geist_Mono } from "next/font/google";
3
3
  import { ThemeProvider } from "@/components/theme-provider";
4
4
  import "./globals.css";
@@ -23,6 +23,13 @@ export const metadata: Metadata = {
23
23
  },
24
24
  };
25
25
 
26
+ export const viewport: Viewport = {
27
+ width: "device-width",
28
+ initialScale: 1,
29
+ maximumScale: 1,
30
+ userScalable: false,
31
+ };
32
+
26
33
  export default function RootLayout({
27
34
  children,
28
35
  }: Readonly<{
@@ -17,10 +17,11 @@ import { useDocsNavigation } from '@/lib/docs-navigation-context'
17
17
  interface CardProps {
18
18
  title: string
19
19
  children?: React.ReactNode
20
- icon?: string
20
+ icon?: string | React.ReactNode // Icon name (string) or custom React node
21
+ iconSize?: 'sm' | 'md' | 'lg' | 'xl' // Size of icon container: sm=32px, md=40px (default), lg=48px, xl=64px
21
22
  href?: string
22
23
  horizontal?: boolean
23
- img?: string
24
+ img?: string // Full-width header image
24
25
  cta?: string
25
26
  arrow?: boolean
26
27
  className?: string
@@ -41,10 +42,19 @@ function getIcon(iconName: string): React.ComponentType<{ className?: string; we
41
42
  return Icon || null
42
43
  }
43
44
 
45
+ // Icon size configurations
46
+ const ICON_SIZES = {
47
+ sm: { container: 'h-8 w-8', icon: 'h-4 w-4', img: 'h-5 w-5' },
48
+ md: { container: 'h-10 w-10', icon: 'h-5 w-5', img: 'h-6 w-6' },
49
+ lg: { container: 'h-12 w-12', icon: 'h-6 w-6', img: 'h-8 w-8' },
50
+ xl: { container: 'h-16 w-16', icon: 'h-8 w-8', img: 'h-12 w-12' },
51
+ }
52
+
44
53
  export function Card({
45
54
  title,
46
55
  children,
47
56
  icon,
57
+ iconSize = 'md',
48
58
  href,
49
59
  horizontal = false,
50
60
  img,
@@ -52,7 +62,11 @@ export function Card({
52
62
  arrow,
53
63
  className,
54
64
  }: CardProps) {
55
- const Icon = icon ? getIcon(icon) : null
65
+ // Check if icon is a string (icon name) or React node
66
+ const isIconString = typeof icon === 'string'
67
+ const Icon = isIconString ? getIcon(icon) : null
68
+ const hasCustomIcon = !isIconString && icon != null
69
+ const sizeConfig = ICON_SIZES[iconSize]
56
70
  const isExternal = href?.startsWith('http') || href?.startsWith('//')
57
71
  const docsNav = useDocsNavigation()
58
72
 
@@ -116,21 +130,35 @@ export function Card({
116
130
  </div>
117
131
  )}
118
132
 
119
- {/* Icon */}
133
+ {/* Icon (Phosphor icon from string) */}
120
134
  {Icon && (
121
135
  <div
122
136
  className={cn(
123
- 'docs-card-icon flex h-10 w-10 shrink-0 items-center justify-center rounded-lg',
137
+ 'docs-card-icon flex shrink-0 items-center justify-center rounded-lg',
138
+ sizeConfig.container,
124
139
  'bg-primary/10 text-primary',
125
140
  horizontal && 'mt-0.5'
126
141
  )}
127
142
  >
128
- <Icon className="h-5 w-5" weight="duotone" />
143
+ <Icon className={sizeConfig.icon} weight="duotone" />
144
+ </div>
145
+ )}
146
+
147
+ {/* Custom Icon (React node - image, svg, etc.) */}
148
+ {hasCustomIcon && (
149
+ <div
150
+ className={cn(
151
+ 'docs-card-icon flex shrink-0 items-center justify-center rounded-lg overflow-hidden',
152
+ sizeConfig.container,
153
+ horizontal && 'mt-0.5'
154
+ )}
155
+ >
156
+ {icon}
129
157
  </div>
130
158
  )}
131
159
 
132
160
  {/* Content */}
133
- <div className={cn('docs-card-content min-w-0', horizontal && 'flex-1', !horizontal && Icon && 'mt-3')}>
161
+ <div className={cn('docs-card-content min-w-0', horizontal && 'flex-1', !horizontal && (Icon || hasCustomIcon) && 'mt-3')}>
134
162
  <div className="flex items-center gap-2">
135
163
  <h3 className="docs-card-title font-semibold text-foreground group-hover:text-primary transition-colors">
136
164
  {title}
@@ -150,10 +150,10 @@ export function DocsSidebar({
150
150
 
151
151
  return (
152
152
  <>
153
- {/* Mobile overlay backdrop */}
153
+ {/* Mobile overlay backdrop - z-[55] to cover header (z-50) but below sidebar (z-60) */}
154
154
  {isMobile && isOpen && (
155
155
  <div
156
- className="fixed inset-0 bg-black/50 z-40 lg:hidden"
156
+ className="fixed inset-0 bg-black/50 z-[55] lg:hidden"
157
157
  onClick={onClose}
158
158
  />
159
159
  )}
@@ -162,9 +162,9 @@ export function DocsSidebar({
162
162
  className={cn(
163
163
  "flex flex-col border-r bg-sidebar border-sidebar-border overflow-hidden",
164
164
  // Desktop: always visible, fixed width
165
- "lg:relative lg:w-72 lg:h-full",
166
- // Mobile: drawer behavior
167
- "fixed inset-y-0 left-0 z-50 w-[280px] h-full",
165
+ "lg:relative lg:w-72 lg:h-full lg:z-auto",
166
+ // Mobile: drawer behavior - z-[60] to appear above header (z-50)
167
+ "fixed inset-y-0 left-0 z-[60] w-[280px] h-full",
168
168
  "transform transition-transform duration-300 ease-in-out",
169
169
  "lg:transform-none lg:translate-x-0",
170
170
  isMobile && !isOpen && "-translate-x-full",
@@ -25,6 +25,7 @@ interface DocsLogo {
25
25
  height?: number
26
26
  light?: string
27
27
  dark?: string
28
+ href?: string // Where logo links to (defaults to "/")
28
29
  }
29
30
 
30
31
  // Header config from docs.json
@@ -133,7 +134,10 @@ export function DocsHeader({
133
134
  </Button>
134
135
  )}
135
136
 
136
- <Link href="/" className="docs-header-logo flex items-center gap-2 sm:gap-3">
137
+ <Link
138
+ href={docsLogo?.href || "/"}
139
+ className="docs-header-logo flex items-center gap-2 sm:gap-3 min-h-[44px] min-w-[44px] touch-manipulation"
140
+ >
137
141
  {hasLightDarkLogos ? (
138
142
  <>
139
143
  {/* Light mode logo */}
@@ -142,7 +146,7 @@ export function DocsHeader({
142
146
  alt={logo.alt}
143
147
  width={logo.width}
144
148
  height={logo.height}
145
- className="w-auto dark:hidden"
149
+ className="w-auto dark:hidden pointer-events-none"
146
150
  style={{ height: logo.height }}
147
151
  priority
148
152
  />
@@ -152,7 +156,7 @@ export function DocsHeader({
152
156
  alt={logo.alt}
153
157
  width={logo.width}
154
158
  height={logo.height}
155
- className="w-auto hidden dark:block"
159
+ className="w-auto hidden dark:block pointer-events-none"
156
160
  style={{ height: logo.height }}
157
161
  priority
158
162
  />
@@ -163,7 +167,7 @@ export function DocsHeader({
163
167
  alt={logo.alt}
164
168
  width={logo.width}
165
169
  height={logo.height}
166
- className="w-auto dark:invert"
170
+ className="w-auto dark:invert pointer-events-none"
167
171
  style={{ height: logo.height }}
168
172
  priority
169
173
  />
@@ -171,7 +175,7 @@ export function DocsHeader({
171
175
  {docsName && (
172
176
  <>
173
177
  <div className="hidden sm:block h-5 w-px bg-border" />
174
- <span className="docs-header-title hidden sm:inline text-sm font-medium text-muted-foreground">
178
+ <span className="docs-header-title hidden sm:inline text-sm font-medium text-muted-foreground pointer-events-none">
175
179
  {docsName}
176
180
  </span>
177
181
  </>
@@ -107,10 +107,10 @@ export function RightSidebar({
107
107
 
108
108
  return (
109
109
  <>
110
- {/* Mobile overlay backdrop */}
110
+ {/* Mobile overlay backdrop - z-[55] to cover header (z-50) but below panel (z-60) */}
111
111
  {isMobile && isRightSidebarOpen && (
112
112
  <div
113
- className="docs-agent-overlay fixed inset-0 bg-black/50 z-40 lg:hidden"
113
+ className="docs-agent-overlay fixed inset-0 bg-black/50 z-[55] lg:hidden"
114
114
  onClick={closeRightSidebar}
115
115
  />
116
116
  )}
@@ -119,9 +119,9 @@ export function RightSidebar({
119
119
  className={cn(
120
120
  "docs-agent-panel border-l border-border bg-background flex flex-col overflow-hidden",
121
121
  // Desktop: always visible, fixed width
122
- "lg:relative lg:w-96 lg:h-full",
123
- // Mobile: drawer behavior from right
124
- "fixed inset-y-0 right-0 z-50 w-[320px] sm:w-[360px] h-full",
122
+ "lg:relative lg:w-96 lg:h-full lg:z-auto",
123
+ // Mobile: drawer behavior from right - z-[60] to appear above header (z-50)
124
+ "fixed inset-y-0 right-0 z-[60] w-[320px] sm:w-[360px] h-full",
125
125
  "transform transition-transform duration-300 ease-in-out",
126
126
  "lg:transform-none lg:translate-x-0",
127
127
  isMobile && !isRightSidebarOpen && "translate-x-full",
@@ -0,0 +1,260 @@
1
+ import { z } from 'zod'
2
+
3
+ /**
4
+ * Domain Configuration Schema (domain.json)
5
+ *
6
+ * Configuration for custom domains on DevDoc projects.
7
+ * Each project can have ONE custom domain for free.
8
+ */
9
+
10
+ // ============================================================================
11
+ // Domain Validation Helpers
12
+ // ============================================================================
13
+
14
+ /**
15
+ * Valid domain pattern - allows subdomains like docs.example.com
16
+ * Does NOT allow:
17
+ * - IP addresses
18
+ * - Ports
19
+ * - Paths
20
+ * - Protocol prefixes
21
+ */
22
+ const domainRegex = /^(?!-)[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*\.[a-zA-Z]{2,}$/
23
+
24
+ /**
25
+ * Reserved domains that cannot be used as custom domains
26
+ */
27
+ const RESERVED_DOMAINS = [
28
+ 'devdoc.sh',
29
+ 'devdoc.io',
30
+ 'devdoc.com',
31
+ 'localhost',
32
+ ]
33
+
34
+ // ============================================================================
35
+ // SEO Schema
36
+ // ============================================================================
37
+
38
+ const domainSeoSchema = z.object({
39
+ /**
40
+ * Canonical URL for SEO - tells search engines this is the primary URL
41
+ * Should match your custom domain with https://
42
+ */
43
+ canonical: z.string().url().optional(),
44
+ })
45
+
46
+ // ============================================================================
47
+ // Settings Schema
48
+ // ============================================================================
49
+
50
+ const domainSettingsSchema = z.object({
51
+ /**
52
+ * Force HTTPS redirects (recommended, default: true)
53
+ */
54
+ forceHttps: z.boolean().default(true),
55
+
56
+ /**
57
+ * WWW redirect preference
58
+ * - "www": Redirect non-www to www (example.com → www.example.com)
59
+ * - "non-www": Redirect www to non-www (www.example.com → example.com)
60
+ * - "none": No redirect, accept both
61
+ */
62
+ wwwRedirect: z.enum(['www', 'non-www', 'none']).default('none'),
63
+ })
64
+
65
+ // ============================================================================
66
+ // Main Domain Configuration Schema
67
+ // ============================================================================
68
+
69
+ export const domainConfigSchema = z.object({
70
+ /**
71
+ * JSON Schema reference (optional)
72
+ */
73
+ $schema: z.string().optional(),
74
+
75
+ /**
76
+ * The custom domain for this project
77
+ * Example: "docs.example.com" or "developer.mycompany.io"
78
+ *
79
+ * Rules:
80
+ * - Must be a valid domain name
81
+ * - Cannot be a DevDoc domain (*.devdoc.sh)
82
+ * - One custom domain per project (free)
83
+ */
84
+ customDomain: z.string()
85
+ .min(4, 'Domain must be at least 4 characters')
86
+ .max(253, 'Domain must be 253 characters or less')
87
+ .regex(domainRegex, 'Invalid domain format. Example: docs.example.com')
88
+ .refine(
89
+ (domain) => !RESERVED_DOMAINS.some(reserved =>
90
+ domain === reserved || domain.endsWith(`.${reserved}`)
91
+ ),
92
+ 'Cannot use a reserved DevDoc domain'
93
+ ),
94
+
95
+ /**
96
+ * SEO configuration for the custom domain
97
+ */
98
+ seo: domainSeoSchema.optional(),
99
+
100
+ /**
101
+ * Domain behavior settings
102
+ */
103
+ settings: domainSettingsSchema.optional(),
104
+ })
105
+
106
+ // ============================================================================
107
+ // Types
108
+ // ============================================================================
109
+
110
+ export type DomainConfig = z.infer<typeof domainConfigSchema>
111
+ export type DomainSeoConfig = z.infer<typeof domainSeoSchema>
112
+ export type DomainSettings = z.infer<typeof domainSettingsSchema>
113
+
114
+ // ============================================================================
115
+ // Domain Status Types (for API/storage)
116
+ // ============================================================================
117
+
118
+ /**
119
+ * Status of a custom domain in the system
120
+ */
121
+ export type DomainStatus =
122
+ | 'pending' // Domain added, awaiting DNS configuration
123
+ | 'dns_verified' // DNS records verified, awaiting SSL
124
+ | 'ssl_provisioning' // SSL certificate being provisioned
125
+ | 'active' // Domain is live and working
126
+ | 'error' // Configuration error
127
+
128
+ /**
129
+ * Custom domain entry stored in the registry
130
+ */
131
+ export interface CustomDomainEntry {
132
+ /** The custom domain (e.g., "docs.example.com") */
133
+ customDomain: string
134
+
135
+ /** The project slug this domain points to */
136
+ projectSlug: string
137
+
138
+ /** Current status of the domain */
139
+ status: DomainStatus
140
+
141
+ /** Vercel domain ID (from Vercel API) */
142
+ vercelDomainId?: string
143
+
144
+ /** TXT record value for domain ownership verification */
145
+ verificationToken?: string
146
+
147
+ /** When the domain was added */
148
+ createdAt: string
149
+
150
+ /** When DNS was verified */
151
+ verifiedAt?: string
152
+
153
+ /** Last time status was checked */
154
+ lastCheckedAt?: string
155
+
156
+ /** Error message if status is "error" */
157
+ errorMessage?: string
158
+
159
+ /** Domain settings from domain.json */
160
+ settings?: DomainSettings
161
+ }
162
+
163
+ // ============================================================================
164
+ // Validation Functions
165
+ // ============================================================================
166
+
167
+ /**
168
+ * Parse and validate domain configuration
169
+ */
170
+ export function parseDomainConfig(data: unknown): DomainConfig {
171
+ const result = domainConfigSchema.safeParse(data)
172
+
173
+ if (!result.success) {
174
+ const issues = result.error.issues || []
175
+ const errors = issues.map((e) =>
176
+ `${e.path.map(String).join('.')}: ${e.message}`
177
+ ).join('\n')
178
+ throw new Error(`Invalid domain.json configuration:\n${errors}`)
179
+ }
180
+
181
+ return result.data
182
+ }
183
+
184
+ /**
185
+ * Safe parse that returns null on failure
186
+ */
187
+ export function safeParseDomainConfig(data: unknown): DomainConfig | null {
188
+ const result = domainConfigSchema.safeParse(data)
189
+ return result.success ? result.data : null
190
+ }
191
+
192
+ /**
193
+ * Validate a domain string format
194
+ */
195
+ export function isValidDomain(domain: string): { valid: boolean; error?: string } {
196
+ if (!domain) {
197
+ return { valid: false, error: 'Domain is required' }
198
+ }
199
+
200
+ if (domain.length < 4) {
201
+ return { valid: false, error: 'Domain must be at least 4 characters' }
202
+ }
203
+
204
+ if (domain.length > 253) {
205
+ return { valid: false, error: 'Domain must be 253 characters or less' }
206
+ }
207
+
208
+ if (!domainRegex.test(domain)) {
209
+ return { valid: false, error: 'Invalid domain format. Example: docs.example.com' }
210
+ }
211
+
212
+ // Check reserved domains
213
+ if (RESERVED_DOMAINS.some(reserved =>
214
+ domain === reserved || domain.endsWith(`.${reserved}`)
215
+ )) {
216
+ return { valid: false, error: 'Cannot use a reserved DevDoc domain' }
217
+ }
218
+
219
+ return { valid: true }
220
+ }
221
+
222
+ /**
223
+ * Normalize a domain (lowercase, trim, remove protocol/path)
224
+ */
225
+ export function normalizeDomain(domain: string): string {
226
+ let normalized = domain.toLowerCase().trim()
227
+
228
+ // Remove protocol if present
229
+ normalized = normalized.replace(/^https?:\/\//, '')
230
+
231
+ // Remove path if present
232
+ normalized = normalized.split('/')[0]
233
+
234
+ // Remove port if present
235
+ normalized = normalized.split(':')[0]
236
+
237
+ return normalized
238
+ }
239
+
240
+ /**
241
+ * Get DNS instructions for a custom domain
242
+ */
243
+ export function getDnsInstructions(customDomain: string): {
244
+ cname: { name: string; value: string }
245
+ txt: { name: string; value: string }
246
+ } {
247
+ const parts = customDomain.split('.')
248
+ const subdomain = parts.length > 2 ? parts[0] : '@'
249
+
250
+ return {
251
+ cname: {
252
+ name: subdomain === '@' ? customDomain : subdomain,
253
+ value: 'cname.devdoc-dns.com',
254
+ },
255
+ txt: {
256
+ name: `_devdoc-verify.${customDomain}`,
257
+ value: '', // Will be filled with actual verification token
258
+ },
259
+ }
260
+ }
@@ -17,6 +17,20 @@ export {
17
17
  type ApiConfig,
18
18
  } from './schema'
19
19
 
20
+ export {
21
+ domainConfigSchema,
22
+ parseDomainConfig,
23
+ safeParseDomainConfig,
24
+ isValidDomain,
25
+ normalizeDomain,
26
+ getDnsInstructions,
27
+ type DomainConfig,
28
+ type DomainSeoConfig,
29
+ type DomainSettings,
30
+ type DomainStatus,
31
+ type CustomDomainEntry,
32
+ } from './domain-schema'
33
+
20
34
  export {
21
35
  loadDocsConfig,
22
36
  safeLoadDocsConfig,