@brainfish-ai/devdoc 0.1.29 → 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.
- package/ai-agents/CLAUDE.md +1 -1
- package/dist/cli/commands/create.d.ts +1 -0
- package/dist/cli/commands/create.js +140 -1
- package/dist/cli/commands/deploy.js +65 -1
- package/dist/cli/commands/domain.d.ts +21 -0
- package/dist/cli/commands/domain.js +386 -0
- package/dist/cli/commands/init.d.ts +1 -0
- package/dist/cli/commands/init.js +188 -1
- package/dist/cli/index.js +32 -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,100 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server'
|
|
2
|
+
import {
|
|
3
|
+
validateApiKey,
|
|
4
|
+
getProjectCustomDomain,
|
|
5
|
+
removeCustomDomain,
|
|
6
|
+
} from '@/lib/storage/blob'
|
|
7
|
+
import { normalizeDomain } from '@/lib/docs/config'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* DELETE /api/domains/remove
|
|
11
|
+
*
|
|
12
|
+
* Remove a custom domain from a project.
|
|
13
|
+
*
|
|
14
|
+
* Headers:
|
|
15
|
+
* Authorization: Bearer <api_key>
|
|
16
|
+
*
|
|
17
|
+
* Body:
|
|
18
|
+
* { customDomain: "docs.example.com" } // Optional, removes project's domain if not specified
|
|
19
|
+
*
|
|
20
|
+
* Response:
|
|
21
|
+
* {
|
|
22
|
+
* success: true,
|
|
23
|
+
* message: "Domain docs.example.com removed successfully"
|
|
24
|
+
* }
|
|
25
|
+
*/
|
|
26
|
+
export async function DELETE(request: NextRequest) {
|
|
27
|
+
try {
|
|
28
|
+
// Validate API key
|
|
29
|
+
const authHeader = request.headers.get('Authorization')
|
|
30
|
+
const apiKey = authHeader?.replace('Bearer ', '')
|
|
31
|
+
|
|
32
|
+
if (!apiKey) {
|
|
33
|
+
return NextResponse.json(
|
|
34
|
+
{ error: 'API key required. Provide via Authorization header.' },
|
|
35
|
+
{ status: 401 }
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const projectSlug = await validateApiKey(apiKey)
|
|
40
|
+
if (!projectSlug) {
|
|
41
|
+
return NextResponse.json(
|
|
42
|
+
{ error: 'Invalid API key' },
|
|
43
|
+
{ status: 403 }
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Parse request body
|
|
48
|
+
const body = await request.json().catch(() => ({}))
|
|
49
|
+
let customDomain = body.customDomain ? normalizeDomain(body.customDomain) : null
|
|
50
|
+
|
|
51
|
+
// If no domain specified, get the project's custom domain
|
|
52
|
+
if (!customDomain) {
|
|
53
|
+
const projectDomain = await getProjectCustomDomain(projectSlug)
|
|
54
|
+
if (!projectDomain) {
|
|
55
|
+
return NextResponse.json(
|
|
56
|
+
{ error: 'No custom domain configured for this project' },
|
|
57
|
+
{ status: 404 }
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
customDomain = projectDomain.customDomain
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Remove the domain
|
|
64
|
+
const result = await removeCustomDomain(customDomain, projectSlug)
|
|
65
|
+
|
|
66
|
+
if (!result.success) {
|
|
67
|
+
return NextResponse.json(
|
|
68
|
+
{ error: result.error },
|
|
69
|
+
{ status: 400 }
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// TODO: In production, also remove from Vercel via API
|
|
74
|
+
// await vercelApi.removeDomain(customDomain)
|
|
75
|
+
|
|
76
|
+
return NextResponse.json({
|
|
77
|
+
success: true,
|
|
78
|
+
message: `Domain ${customDomain} removed successfully`,
|
|
79
|
+
domain: customDomain,
|
|
80
|
+
projectSlug,
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
} catch (error) {
|
|
84
|
+
console.error('[Domains API] Error removing domain:', error)
|
|
85
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
86
|
+
return NextResponse.json(
|
|
87
|
+
{ error: 'Failed to remove domain', details: message },
|
|
88
|
+
{ status: 500 }
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* POST /api/domains/remove
|
|
95
|
+
*
|
|
96
|
+
* Alternative method for removing domain (some clients don't support DELETE with body)
|
|
97
|
+
*/
|
|
98
|
+
export async function POST(request: NextRequest) {
|
|
99
|
+
return DELETE(request)
|
|
100
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server'
|
|
2
|
+
import {
|
|
3
|
+
validateApiKey,
|
|
4
|
+
getProjectCustomDomain,
|
|
5
|
+
getCustomDomainEntry,
|
|
6
|
+
} from '@/lib/storage/blob'
|
|
7
|
+
import { normalizeDomain, getDnsInstructions } from '@/lib/docs/config'
|
|
8
|
+
import { getProjectUrl } from '@/lib/multi-tenant/context'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* GET /api/domains/status
|
|
12
|
+
*
|
|
13
|
+
* Get the status of a custom domain.
|
|
14
|
+
*
|
|
15
|
+
* Headers:
|
|
16
|
+
* Authorization: Bearer <api_key>
|
|
17
|
+
*
|
|
18
|
+
* Query:
|
|
19
|
+
* ?domain=docs.example.com // Optional, uses project's domain if not specified
|
|
20
|
+
*
|
|
21
|
+
* Response:
|
|
22
|
+
* {
|
|
23
|
+
* domain: "docs.example.com",
|
|
24
|
+
* status: "active",
|
|
25
|
+
* projectSlug: "my-project",
|
|
26
|
+
* projectUrl: "https://my-project.devdoc.sh",
|
|
27
|
+
* customUrl: "https://docs.example.com",
|
|
28
|
+
* createdAt: "2026-01-24T...",
|
|
29
|
+
* verifiedAt: "2026-01-24T..."
|
|
30
|
+
* }
|
|
31
|
+
*/
|
|
32
|
+
export async function GET(request: NextRequest) {
|
|
33
|
+
try {
|
|
34
|
+
// Validate API key
|
|
35
|
+
const authHeader = request.headers.get('Authorization')
|
|
36
|
+
const apiKey = authHeader?.replace('Bearer ', '')
|
|
37
|
+
|
|
38
|
+
if (!apiKey) {
|
|
39
|
+
return NextResponse.json(
|
|
40
|
+
{ error: 'API key required. Provide via Authorization header.' },
|
|
41
|
+
{ status: 401 }
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const projectSlug = await validateApiKey(apiKey)
|
|
46
|
+
if (!projectSlug) {
|
|
47
|
+
return NextResponse.json(
|
|
48
|
+
{ error: 'Invalid API key' },
|
|
49
|
+
{ status: 403 }
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Get domain from query or use project's domain
|
|
54
|
+
const { searchParams } = new URL(request.url)
|
|
55
|
+
let customDomain = searchParams.get('domain')
|
|
56
|
+
|
|
57
|
+
if (customDomain) {
|
|
58
|
+
customDomain = normalizeDomain(customDomain)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
let domainEntry
|
|
62
|
+
|
|
63
|
+
if (customDomain) {
|
|
64
|
+
// Get specific domain
|
|
65
|
+
domainEntry = await getCustomDomainEntry(customDomain)
|
|
66
|
+
|
|
67
|
+
if (!domainEntry) {
|
|
68
|
+
return NextResponse.json(
|
|
69
|
+
{ error: `Domain ${customDomain} not found` },
|
|
70
|
+
{ status: 404 }
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Verify domain belongs to this project
|
|
75
|
+
if (domainEntry.projectSlug !== projectSlug) {
|
|
76
|
+
return NextResponse.json(
|
|
77
|
+
{ error: `Domain ${customDomain} does not belong to this project` },
|
|
78
|
+
{ status: 403 }
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
} else {
|
|
82
|
+
// Get project's custom domain
|
|
83
|
+
domainEntry = await getProjectCustomDomain(projectSlug)
|
|
84
|
+
|
|
85
|
+
if (!domainEntry) {
|
|
86
|
+
return NextResponse.json({
|
|
87
|
+
hasCustomDomain: false,
|
|
88
|
+
projectSlug,
|
|
89
|
+
projectUrl: getProjectUrl(projectSlug),
|
|
90
|
+
message: 'No custom domain configured. Use /api/domains/add to add one.',
|
|
91
|
+
})
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Build response based on status
|
|
96
|
+
const projectUrl = getProjectUrl(projectSlug)
|
|
97
|
+
const response: Record<string, unknown> = {
|
|
98
|
+
hasCustomDomain: true,
|
|
99
|
+
domain: domainEntry.customDomain,
|
|
100
|
+
status: domainEntry.status,
|
|
101
|
+
projectSlug,
|
|
102
|
+
projectUrl,
|
|
103
|
+
createdAt: domainEntry.createdAt,
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Add status-specific information
|
|
107
|
+
switch (domainEntry.status) {
|
|
108
|
+
case 'pending': {
|
|
109
|
+
const dnsInstructions = getDnsInstructions(domainEntry.customDomain)
|
|
110
|
+
dnsInstructions.txt.value = domainEntry.verificationToken || ''
|
|
111
|
+
|
|
112
|
+
response.message = 'Waiting for DNS configuration'
|
|
113
|
+
response.dnsRecords = {
|
|
114
|
+
cname: dnsInstructions.cname,
|
|
115
|
+
txt: dnsInstructions.txt,
|
|
116
|
+
}
|
|
117
|
+
response.nextStep = 'Add the DNS records above, then run "devdoc domain verify"'
|
|
118
|
+
break
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
case 'dns_verified':
|
|
122
|
+
response.message = 'DNS verified, SSL certificate provisioning in progress'
|
|
123
|
+
response.verifiedAt = domainEntry.verifiedAt
|
|
124
|
+
response.nextStep = 'SSL certificate should be ready within 1-24 hours'
|
|
125
|
+
break
|
|
126
|
+
|
|
127
|
+
case 'ssl_provisioning':
|
|
128
|
+
response.message = 'SSL certificate being provisioned'
|
|
129
|
+
response.verifiedAt = domainEntry.verifiedAt
|
|
130
|
+
response.nextStep = 'Almost ready! Check back in a few minutes'
|
|
131
|
+
break
|
|
132
|
+
|
|
133
|
+
case 'active':
|
|
134
|
+
response.message = 'Domain is active and working'
|
|
135
|
+
response.customUrl = `https://${domainEntry.customDomain}`
|
|
136
|
+
response.verifiedAt = domainEntry.verifiedAt
|
|
137
|
+
break
|
|
138
|
+
|
|
139
|
+
case 'error':
|
|
140
|
+
response.message = 'Domain configuration error'
|
|
141
|
+
response.error = domainEntry.errorMessage
|
|
142
|
+
response.nextStep = 'Check DNS configuration and try verifying again'
|
|
143
|
+
break
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
response.lastCheckedAt = domainEntry.lastCheckedAt
|
|
147
|
+
|
|
148
|
+
return NextResponse.json(response)
|
|
149
|
+
|
|
150
|
+
} catch (error) {
|
|
151
|
+
console.error('[Domains API] Error getting status:', error)
|
|
152
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
153
|
+
return NextResponse.json(
|
|
154
|
+
{ error: 'Failed to get domain status', details: message },
|
|
155
|
+
{ status: 500 }
|
|
156
|
+
)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server'
|
|
2
|
+
import {
|
|
3
|
+
validateApiKey,
|
|
4
|
+
getProjectCustomDomain,
|
|
5
|
+
getCustomDomainEntry,
|
|
6
|
+
updateCustomDomainStatus,
|
|
7
|
+
} from '@/lib/storage/blob'
|
|
8
|
+
import { normalizeDomain } from '@/lib/docs/config'
|
|
9
|
+
import dns from 'dns'
|
|
10
|
+
import { promisify } from 'util'
|
|
11
|
+
|
|
12
|
+
const resolveCname = promisify(dns.resolveCname)
|
|
13
|
+
const resolveTxt = promisify(dns.resolveTxt)
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* POST /api/domains/verify
|
|
17
|
+
*
|
|
18
|
+
* Verify DNS configuration for a custom domain.
|
|
19
|
+
* Checks CNAME and TXT records, then triggers SSL provisioning.
|
|
20
|
+
*
|
|
21
|
+
* Headers:
|
|
22
|
+
* Authorization: Bearer <api_key>
|
|
23
|
+
*
|
|
24
|
+
* Body:
|
|
25
|
+
* { customDomain: "docs.example.com" } // Optional, uses project's domain if not specified
|
|
26
|
+
*
|
|
27
|
+
* Response:
|
|
28
|
+
* {
|
|
29
|
+
* success: true,
|
|
30
|
+
* domain: "docs.example.com",
|
|
31
|
+
* status: "dns_verified",
|
|
32
|
+
* checks: {
|
|
33
|
+
* cname: { found: true, value: "cname.devdoc-dns.com" },
|
|
34
|
+
* txt: { found: true, verified: true }
|
|
35
|
+
* }
|
|
36
|
+
* }
|
|
37
|
+
*/
|
|
38
|
+
export async function POST(request: NextRequest) {
|
|
39
|
+
try {
|
|
40
|
+
// Validate API key
|
|
41
|
+
const authHeader = request.headers.get('Authorization')
|
|
42
|
+
const apiKey = authHeader?.replace('Bearer ', '')
|
|
43
|
+
|
|
44
|
+
if (!apiKey) {
|
|
45
|
+
return NextResponse.json(
|
|
46
|
+
{ error: 'API key required. Provide via Authorization header.' },
|
|
47
|
+
{ status: 401 }
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const projectSlug = await validateApiKey(apiKey)
|
|
52
|
+
if (!projectSlug) {
|
|
53
|
+
return NextResponse.json(
|
|
54
|
+
{ error: 'Invalid API key' },
|
|
55
|
+
{ status: 403 }
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Parse request body
|
|
60
|
+
const body = await request.json().catch(() => ({}))
|
|
61
|
+
let customDomain = body.customDomain ? normalizeDomain(body.customDomain) : null
|
|
62
|
+
|
|
63
|
+
// If no domain specified, get the project's custom domain
|
|
64
|
+
if (!customDomain) {
|
|
65
|
+
const projectDomain = await getProjectCustomDomain(projectSlug)
|
|
66
|
+
if (!projectDomain) {
|
|
67
|
+
return NextResponse.json(
|
|
68
|
+
{ error: 'No custom domain configured for this project. Add one first with /api/domains/add' },
|
|
69
|
+
{ status: 404 }
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
customDomain = projectDomain.customDomain
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Get domain entry
|
|
76
|
+
const domainEntry = await getCustomDomainEntry(customDomain)
|
|
77
|
+
if (!domainEntry) {
|
|
78
|
+
return NextResponse.json(
|
|
79
|
+
{ error: `Domain ${customDomain} not found` },
|
|
80
|
+
{ status: 404 }
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Verify domain belongs to this project
|
|
85
|
+
if (domainEntry.projectSlug !== projectSlug) {
|
|
86
|
+
return NextResponse.json(
|
|
87
|
+
{ error: `Domain ${customDomain} does not belong to this project` },
|
|
88
|
+
{ status: 403 }
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Check DNS records
|
|
93
|
+
const checks = {
|
|
94
|
+
cname: { found: false, value: null as string | null, expected: 'cname.devdoc-dns.com' },
|
|
95
|
+
txt: { found: false, verified: false, expected: domainEntry.verificationToken },
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Check CNAME record
|
|
99
|
+
try {
|
|
100
|
+
const cnameRecords = await resolveCname(customDomain)
|
|
101
|
+
if (cnameRecords && cnameRecords.length > 0) {
|
|
102
|
+
checks.cname.found = true
|
|
103
|
+
checks.cname.value = cnameRecords[0]
|
|
104
|
+
}
|
|
105
|
+
} catch {
|
|
106
|
+
// CNAME not found or DNS error
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Check TXT record for verification
|
|
110
|
+
const txtRecordName = `_devdoc-verify.${customDomain}`
|
|
111
|
+
try {
|
|
112
|
+
const txtRecords = await resolveTxt(txtRecordName)
|
|
113
|
+
if (txtRecords && txtRecords.length > 0) {
|
|
114
|
+
checks.txt.found = true
|
|
115
|
+
// TXT records can be arrays, flatten and check
|
|
116
|
+
const allTxtValues = txtRecords.flat()
|
|
117
|
+
checks.txt.verified = allTxtValues.some(
|
|
118
|
+
val => val === domainEntry.verificationToken
|
|
119
|
+
)
|
|
120
|
+
}
|
|
121
|
+
} catch {
|
|
122
|
+
// TXT not found or DNS error
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Determine overall status
|
|
126
|
+
const cnameValid = checks.cname.found &&
|
|
127
|
+
checks.cname.value?.toLowerCase().includes('devdoc')
|
|
128
|
+
const txtValid = checks.txt.found && checks.txt.verified
|
|
129
|
+
|
|
130
|
+
let newStatus = domainEntry.status
|
|
131
|
+
let message = ''
|
|
132
|
+
|
|
133
|
+
if (cnameValid && txtValid) {
|
|
134
|
+
// DNS is verified, update status
|
|
135
|
+
newStatus = 'dns_verified'
|
|
136
|
+
message = 'DNS verified! SSL certificate will be provisioned automatically (1-24 hours).'
|
|
137
|
+
|
|
138
|
+
await updateCustomDomainStatus(customDomain, 'dns_verified')
|
|
139
|
+
|
|
140
|
+
// In production, this would trigger Vercel API to add domain
|
|
141
|
+
// For now, we simulate SSL provisioning by updating status
|
|
142
|
+
// TODO: Integrate with Vercel Domains API
|
|
143
|
+
|
|
144
|
+
} else if (cnameValid && !txtValid) {
|
|
145
|
+
message = 'CNAME record found, but TXT verification record is missing or incorrect.'
|
|
146
|
+
} else if (!cnameValid && txtValid) {
|
|
147
|
+
message = 'TXT verification found, but CNAME record is missing or incorrect.'
|
|
148
|
+
} else {
|
|
149
|
+
message = 'DNS records not found. Please add the required CNAME and TXT records.'
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return NextResponse.json({
|
|
153
|
+
success: cnameValid && txtValid,
|
|
154
|
+
domain: customDomain,
|
|
155
|
+
projectSlug,
|
|
156
|
+
status: newStatus,
|
|
157
|
+
message,
|
|
158
|
+
checks: {
|
|
159
|
+
cname: {
|
|
160
|
+
found: checks.cname.found,
|
|
161
|
+
value: checks.cname.value,
|
|
162
|
+
valid: cnameValid,
|
|
163
|
+
expected: checks.cname.expected,
|
|
164
|
+
},
|
|
165
|
+
txt: {
|
|
166
|
+
found: checks.txt.found,
|
|
167
|
+
verified: checks.txt.verified,
|
|
168
|
+
expected: checks.txt.expected,
|
|
169
|
+
},
|
|
170
|
+
},
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
} catch (error) {
|
|
174
|
+
console.error('[Domains API] Error verifying domain:', error)
|
|
175
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
176
|
+
return NextResponse.json(
|
|
177
|
+
{ error: 'Failed to verify domain', details: message },
|
|
178
|
+
{ status: 500 }
|
|
179
|
+
)
|
|
180
|
+
}
|
|
181
|
+
}
|
|
@@ -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}
|