@brainfish-ai/devdoc 0.1.30 → 0.1.32
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/ai-agents/CLAUDE.md +1 -1
- package/dist/cli/commands/deploy.js +65 -1
- package/dist/cli/commands/domain.d.ts +21 -0
- package/dist/cli/commands/domain.js +407 -0
- package/dist/cli/index.js +30 -1
- package/package.json +1 -1
- package/renderer/app/api/domains/add/route.ts +132 -0
- package/renderer/app/api/domains/lookup/route.ts +43 -0
- package/renderer/app/api/domains/remove/route.ts +100 -0
- package/renderer/app/api/domains/status/route.ts +158 -0
- package/renderer/app/api/domains/verify/route.ts +181 -0
- package/renderer/app/api/local-assets/[...path]/route.ts +122 -0
- package/renderer/app/globals.css +9 -0
- package/renderer/app/layout.tsx +8 -1
- package/renderer/components/docs/mdx/cards.tsx +35 -7
- package/renderer/components/docs/navigation/sidebar.tsx +5 -5
- package/renderer/components/docs-header.tsx +9 -5
- package/renderer/components/docs-viewer/sidebar/right-sidebar.tsx +5 -5
- package/renderer/lib/docs/config/domain-schema.ts +260 -0
- package/renderer/lib/docs/config/index.ts +14 -0
- package/renderer/lib/storage/blob.ts +242 -4
- package/renderer/public/file.svg +0 -1
- package/renderer/public/globe.svg +0 -1
- package/renderer/public/logo.svg +0 -9
- package/renderer/public/window.svg +0 -1
|
@@ -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
|
+
}
|
package/renderer/app/globals.css
CHANGED
|
@@ -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;
|
package/renderer/app/layout.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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=
|
|
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-
|
|
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-
|
|
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
|
|
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-
|
|
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-
|
|
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,
|