@antigenic-oss/paint 0.1.0
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/LICENSE +178 -0
- package/NOTICE +4 -0
- package/README.md +180 -0
- package/bin/paint.js +266 -0
- package/next-env.d.ts +6 -0
- package/next.config.ts +19 -0
- package/package.json +81 -0
- package/postcss.config.mjs +8 -0
- package/public/dev-editor-inspector.js +1872 -0
- package/src/app/api/claude/analyze/route.ts +319 -0
- package/src/app/api/claude/apply/route.ts +185 -0
- package/src/app/api/claude/pick-folder/route.ts +64 -0
- package/src/app/api/claude/scan/route.ts +221 -0
- package/src/app/api/claude/status/route.ts +55 -0
- package/src/app/api/project/scan/route.ts +634 -0
- package/src/app/api/project-scan/css-variables/route.ts +238 -0
- package/src/app/api/project-scan/route.ts +40 -0
- package/src/app/api/project-scan/tailwind-config/route.ts +172 -0
- package/src/app/api/proxy/[[...path]]/route.ts +2400 -0
- package/src/app/docs/DocsClient.tsx +322 -0
- package/src/app/docs/layout.tsx +7 -0
- package/src/app/docs/page.tsx +855 -0
- package/src/app/globals.css +176 -0
- package/src/app/layout.tsx +19 -0
- package/src/app/page.tsx +46 -0
- package/src/bridge/api-handlers.ts +885 -0
- package/src/bridge/proxy-handler.ts +329 -0
- package/src/bridge/server.ts +113 -0
- package/src/components/BreakpointTabs.tsx +72 -0
- package/src/components/ChangeSummaryModal.tsx +267 -0
- package/src/components/ConnectModal.tsx +994 -0
- package/src/components/Editor.tsx +90 -0
- package/src/components/PageSelector.tsx +208 -0
- package/src/components/PreviewFrame.tsx +299 -0
- package/src/components/ProjectFolderBanner.tsx +91 -0
- package/src/components/ResponsiveToolbar.tsx +222 -0
- package/src/components/TargetSelector.tsx +243 -0
- package/src/components/TopBar.tsx +315 -0
- package/src/components/common/CollapsibleSection.tsx +36 -0
- package/src/components/common/ColorPicker.tsx +920 -0
- package/src/components/common/EditablePre.tsx +136 -0
- package/src/components/common/ErrorBoundary.tsx +65 -0
- package/src/components/common/ResizablePanel.tsx +83 -0
- package/src/components/common/ScanAnimation.tsx +76 -0
- package/src/components/common/ToastContainer.tsx +97 -0
- package/src/components/common/UnitInput.tsx +77 -0
- package/src/components/common/VariableColorPicker.tsx +622 -0
- package/src/components/left-panel/AddElementPanel.tsx +237 -0
- package/src/components/left-panel/ComponentsPanel.tsx +609 -0
- package/src/components/left-panel/IconSidebar.tsx +99 -0
- package/src/components/left-panel/LayerNode.tsx +874 -0
- package/src/components/left-panel/LayerSearch.tsx +23 -0
- package/src/components/left-panel/LayersPanel.tsx +52 -0
- package/src/components/left-panel/LeftPanel.tsx +122 -0
- package/src/components/left-panel/PagesPanel.tsx +114 -0
- package/src/components/left-panel/icons.tsx +162 -0
- package/src/components/left-panel/terminal/ScanOverlay.tsx +66 -0
- package/src/components/left-panel/terminal/TerminalPanel.tsx +217 -0
- package/src/components/right-panel/ElementLogBox.tsx +248 -0
- package/src/components/right-panel/PanelTabs.tsx +83 -0
- package/src/components/right-panel/RightPanel.tsx +41 -0
- package/src/components/right-panel/changes/AiScanResultPanel.tsx +285 -0
- package/src/components/right-panel/changes/ChangeEntry.tsx +59 -0
- package/src/components/right-panel/changes/ChangelogActions.tsx +105 -0
- package/src/components/right-panel/changes/ChangesPanel.tsx +1474 -0
- package/src/components/right-panel/claude/ApplyConfirmModal.tsx +376 -0
- package/src/components/right-panel/claude/ClaudeErrorState.tsx +125 -0
- package/src/components/right-panel/claude/ClaudeIntegrationPanel.tsx +482 -0
- package/src/components/right-panel/claude/ClaudeProgressIndicator.tsx +76 -0
- package/src/components/right-panel/claude/DiffCard.tsx +130 -0
- package/src/components/right-panel/claude/DiffViewer.tsx +54 -0
- package/src/components/right-panel/claude/ProjectRootSelector.tsx +275 -0
- package/src/components/right-panel/claude/ResultsSummary.tsx +119 -0
- package/src/components/right-panel/claude/SetupFlow.tsx +315 -0
- package/src/components/right-panel/console/ConsolePanel.tsx +209 -0
- package/src/components/right-panel/design/AppearanceSection.tsx +703 -0
- package/src/components/right-panel/design/BackgroundSection.tsx +516 -0
- package/src/components/right-panel/design/BorderSection.tsx +161 -0
- package/src/components/right-panel/design/CSSRawView.tsx +412 -0
- package/src/components/right-panel/design/DesignCSSTabToggle.tsx +51 -0
- package/src/components/right-panel/design/DesignPanel.tsx +275 -0
- package/src/components/right-panel/design/ElementBreadcrumb.tsx +51 -0
- package/src/components/right-panel/design/GradientEditor.tsx +726 -0
- package/src/components/right-panel/design/LayoutSection.tsx +1948 -0
- package/src/components/right-panel/design/PositionSection.tsx +865 -0
- package/src/components/right-panel/design/PropertiesSection.tsx +86 -0
- package/src/components/right-panel/design/SVGSection.tsx +361 -0
- package/src/components/right-panel/design/ShadowBlurSection.tsx +227 -0
- package/src/components/right-panel/design/SizeSection.tsx +183 -0
- package/src/components/right-panel/design/TextSection.tsx +719 -0
- package/src/components/right-panel/design/icons.tsx +948 -0
- package/src/components/right-panel/design/inputs/BoxModelPreview.tsx +467 -0
- package/src/components/right-panel/design/inputs/ColorInput.tsx +43 -0
- package/src/components/right-panel/design/inputs/CompactInput.tsx +333 -0
- package/src/components/right-panel/design/inputs/DraggableLabel.tsx +118 -0
- package/src/components/right-panel/design/inputs/IconToggleGroup.tsx +54 -0
- package/src/components/right-panel/design/inputs/LinkedInputPair.tsx +174 -0
- package/src/components/right-panel/design/inputs/SectionHeader.tsx +79 -0
- package/src/components/right-panel/variables/VariablesPanel.tsx +388 -0
- package/src/hooks/useBridge.ts +95 -0
- package/src/hooks/useChangeTracker.ts +563 -0
- package/src/hooks/useClaudeAPI.ts +118 -0
- package/src/hooks/useDOMTree.ts +25 -0
- package/src/hooks/useKeyboardShortcuts.ts +76 -0
- package/src/hooks/usePostMessage.ts +589 -0
- package/src/hooks/useProjectScan.ts +204 -0
- package/src/hooks/useResizable.ts +20 -0
- package/src/hooks/useSelectedElement.ts +51 -0
- package/src/hooks/useTargetUrl.ts +81 -0
- package/src/inspector/DOMTraverser.ts +71 -0
- package/src/inspector/ElementSelector.ts +23 -0
- package/src/inspector/HoverHighlighter.ts +54 -0
- package/src/inspector/SelectionHighlighter.ts +27 -0
- package/src/inspector/StyleExtractor.ts +19 -0
- package/src/inspector/inspector.ts +17 -0
- package/src/inspector/messaging.ts +30 -0
- package/src/lib/apiBase.ts +15 -0
- package/src/lib/classifyElement.ts +430 -0
- package/src/lib/claude-bin.ts +197 -0
- package/src/lib/claude-stream.ts +158 -0
- package/src/lib/clientProjectScanner.ts +344 -0
- package/src/lib/componentMatcher.ts +156 -0
- package/src/lib/constants.ts +573 -0
- package/src/lib/cssVariableUtils.ts +409 -0
- package/src/lib/diffParser.ts +206 -0
- package/src/lib/folderPicker.ts +84 -0
- package/src/lib/gradientParser.ts +160 -0
- package/src/lib/projectScanner.ts +355 -0
- package/src/lib/promptBuilder.ts +402 -0
- package/src/lib/shadowParser.ts +124 -0
- package/src/lib/tailwindClassParser.ts +248 -0
- package/src/lib/textShadowUtils.ts +106 -0
- package/src/lib/utils.ts +299 -0
- package/src/lib/validatePath.ts +40 -0
- package/src/proxy.ts +92 -0
- package/src/server/terminal-server.ts +104 -0
- package/src/store/changeSlice.ts +288 -0
- package/src/store/claudeSlice.ts +222 -0
- package/src/store/componentSlice.ts +90 -0
- package/src/store/consoleSlice.ts +51 -0
- package/src/store/cssVariableSlice.ts +94 -0
- package/src/store/elementSlice.ts +78 -0
- package/src/store/index.ts +35 -0
- package/src/store/terminalSlice.ts +30 -0
- package/src/store/treeSlice.ts +69 -0
- package/src/store/uiSlice.ts +327 -0
- package/src/types/changelog.ts +49 -0
- package/src/types/claude.ts +131 -0
- package/src/types/component.ts +49 -0
- package/src/types/cssVariables.ts +18 -0
- package/src/types/element.ts +21 -0
- package/src/types/file-system-access.d.ts +27 -0
- package/src/types/gradient.ts +12 -0
- package/src/types/messages.ts +392 -0
- package/src/types/shadow.ts +8 -0
- package/src/types/tree.ts +9 -0
- package/tsconfig.json +42 -0
- package/tsconfig.server.json +12 -0
|
@@ -0,0 +1,634 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
import { existsSync, statSync, readdirSync, readFileSync } from 'node:fs'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { homedir } from 'node:os'
|
|
5
|
+
import type {
|
|
6
|
+
ProjectScanResult,
|
|
7
|
+
ComponentEntry,
|
|
8
|
+
RouteEntry,
|
|
9
|
+
FileMap,
|
|
10
|
+
} from '@/types/claude'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Validate that projectRoot is an absolute path, exists as a directory,
|
|
14
|
+
* and resides under the user's HOME directory.
|
|
15
|
+
*/
|
|
16
|
+
function validateProjectRoot(projectRoot: string): string | null {
|
|
17
|
+
if (!path.isAbsolute(projectRoot)) {
|
|
18
|
+
return 'projectRoot must be an absolute path'
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const resolvedHome = path.resolve(homedir())
|
|
22
|
+
const resolved = path.resolve(projectRoot)
|
|
23
|
+
|
|
24
|
+
if (
|
|
25
|
+
!resolved.startsWith(resolvedHome + path.sep) &&
|
|
26
|
+
resolved !== resolvedHome
|
|
27
|
+
) {
|
|
28
|
+
return 'projectRoot must be under the user home directory'
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (!existsSync(resolved)) {
|
|
32
|
+
return 'projectRoot does not exist'
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
const stat = statSync(resolved)
|
|
37
|
+
if (!stat.isDirectory()) {
|
|
38
|
+
return 'projectRoot is not a directory'
|
|
39
|
+
}
|
|
40
|
+
} catch {
|
|
41
|
+
return 'Unable to stat projectRoot'
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return null
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Read and parse package.json from the project root, returning null on failure. */
|
|
48
|
+
function readPackageJson(root: string): Record<string, unknown> | null {
|
|
49
|
+
const pkgPath = path.join(root, 'package.json')
|
|
50
|
+
if (!existsSync(pkgPath)) return null
|
|
51
|
+
try {
|
|
52
|
+
return JSON.parse(readFileSync(pkgPath, 'utf-8'))
|
|
53
|
+
} catch {
|
|
54
|
+
return null
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Detect the primary framework from package.json dependencies and project files. */
|
|
59
|
+
function detectFramework(
|
|
60
|
+
root: string,
|
|
61
|
+
pkg: Record<string, unknown> | null,
|
|
62
|
+
): string | null {
|
|
63
|
+
// Check pubspec.yaml for Flutter (no package.json needed)
|
|
64
|
+
if (existsSync(path.join(root, 'pubspec.yaml'))) return 'flutter'
|
|
65
|
+
|
|
66
|
+
if (!pkg) return null
|
|
67
|
+
const allDeps: Record<string, string> = {
|
|
68
|
+
...((pkg.dependencies as Record<string, string>) ?? {}),
|
|
69
|
+
...((pkg.devDependencies as Record<string, string>) ?? {}),
|
|
70
|
+
}
|
|
71
|
+
if ('next' in allDeps) return 'next'
|
|
72
|
+
if ('nuxt' in allDeps) return 'nuxt'
|
|
73
|
+
if ('astro' in allDeps) return 'astro'
|
|
74
|
+
if ('@sveltejs/kit' in allDeps || 'svelte' in allDeps) return 'svelte'
|
|
75
|
+
if ('@angular/core' in allDeps) return 'angular'
|
|
76
|
+
if ('react-native' in allDeps) return 'react-native'
|
|
77
|
+
if ('vue' in allDeps) return 'vue'
|
|
78
|
+
if ('react' in allDeps) return 'react'
|
|
79
|
+
return null
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Detect CSS strategies from config files and dependencies. */
|
|
83
|
+
function detectCssStrategy(
|
|
84
|
+
root: string,
|
|
85
|
+
pkg: Record<string, unknown> | null,
|
|
86
|
+
): string[] {
|
|
87
|
+
const strategies: string[] = []
|
|
88
|
+
const allDeps: Record<string, string> = pkg
|
|
89
|
+
? {
|
|
90
|
+
...((pkg.dependencies as Record<string, string>) ?? {}),
|
|
91
|
+
...((pkg.devDependencies as Record<string, string>) ?? {}),
|
|
92
|
+
}
|
|
93
|
+
: {}
|
|
94
|
+
|
|
95
|
+
// Tailwind
|
|
96
|
+
const tailwindConfigs = [
|
|
97
|
+
'tailwind.config.js',
|
|
98
|
+
'tailwind.config.ts',
|
|
99
|
+
'tailwind.config.mjs',
|
|
100
|
+
'tailwind.config.cjs',
|
|
101
|
+
]
|
|
102
|
+
if (
|
|
103
|
+
tailwindConfigs.some((f) => existsSync(path.join(root, f))) ||
|
|
104
|
+
'tailwindcss' in allDeps
|
|
105
|
+
) {
|
|
106
|
+
strategies.push('tailwind')
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// CSS Modules — check for *.module.css in src/
|
|
110
|
+
const srcDir = path.join(root, 'src')
|
|
111
|
+
if (existsSync(srcDir)) {
|
|
112
|
+
try {
|
|
113
|
+
const hasCssModules = findFiles(srcDir, /\.module\.(css|scss|sass)$/, 1)
|
|
114
|
+
if (hasCssModules.length > 0) strategies.push('css-modules')
|
|
115
|
+
} catch {
|
|
116
|
+
/* ignore */
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// styled-components / emotion
|
|
121
|
+
if ('styled-components' in allDeps) strategies.push('styled-components')
|
|
122
|
+
if ('@emotion/react' in allDeps || '@emotion/styled' in allDeps)
|
|
123
|
+
strategies.push('emotion')
|
|
124
|
+
|
|
125
|
+
// Sass
|
|
126
|
+
if ('sass' in allDeps || 'node-sass' in allDeps) strategies.push('sass')
|
|
127
|
+
|
|
128
|
+
// Vanilla CSS fallback — check for .css files in src/
|
|
129
|
+
if (strategies.length === 0) strategies.push('vanilla-css')
|
|
130
|
+
|
|
131
|
+
return strategies
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Shallow recursive file search (max 3 levels deep) that stops after `limit` matches. */
|
|
135
|
+
function findFiles(
|
|
136
|
+
dir: string,
|
|
137
|
+
pattern: RegExp,
|
|
138
|
+
limit: number,
|
|
139
|
+
depth = 0,
|
|
140
|
+
): string[] {
|
|
141
|
+
if (depth > 3 || limit <= 0) return []
|
|
142
|
+
const results: string[] = []
|
|
143
|
+
try {
|
|
144
|
+
const entries = readdirSync(dir, { withFileTypes: true })
|
|
145
|
+
for (const entry of entries) {
|
|
146
|
+
if (results.length >= limit) break
|
|
147
|
+
if (entry.name.startsWith('.') || entry.name === 'node_modules') continue
|
|
148
|
+
const fullPath = path.join(dir, entry.name)
|
|
149
|
+
if (entry.isFile() && pattern.test(entry.name)) {
|
|
150
|
+
results.push(fullPath)
|
|
151
|
+
} else if (entry.isDirectory()) {
|
|
152
|
+
results.push(
|
|
153
|
+
...findFiles(fullPath, pattern, limit - results.length, depth + 1),
|
|
154
|
+
)
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
} catch {
|
|
158
|
+
/* ignore permission errors */
|
|
159
|
+
}
|
|
160
|
+
return results
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Find key CSS files in the project (up to 10). */
|
|
164
|
+
function findCssFiles(root: string): string[] {
|
|
165
|
+
const cssFiles: string[] = []
|
|
166
|
+
|
|
167
|
+
// Check for tailwind config
|
|
168
|
+
const tailwindConfigs = [
|
|
169
|
+
'tailwind.config.js',
|
|
170
|
+
'tailwind.config.ts',
|
|
171
|
+
'tailwind.config.mjs',
|
|
172
|
+
'tailwind.config.cjs',
|
|
173
|
+
]
|
|
174
|
+
for (const f of tailwindConfigs) {
|
|
175
|
+
if (existsSync(path.join(root, f))) {
|
|
176
|
+
cssFiles.push(f)
|
|
177
|
+
break
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Search src/ for CSS files
|
|
182
|
+
const srcDir = path.join(root, 'src')
|
|
183
|
+
if (existsSync(srcDir)) {
|
|
184
|
+
const found = findFiles(srcDir, /\.(css|scss|sass)$/, 10)
|
|
185
|
+
for (const f of found) {
|
|
186
|
+
cssFiles.push(path.relative(root, f))
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Also check root-level globals
|
|
191
|
+
for (const name of ['globals.css', 'global.css', 'styles.css', 'index.css']) {
|
|
192
|
+
if (existsSync(path.join(root, name)) && !cssFiles.includes(name)) {
|
|
193
|
+
cssFiles.push(name)
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return cssFiles.slice(0, 10)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** List top-level directories under src/. */
|
|
201
|
+
function listSrcDirs(root: string): string[] {
|
|
202
|
+
const srcDir = path.join(root, 'src')
|
|
203
|
+
if (!existsSync(srcDir)) return []
|
|
204
|
+
try {
|
|
205
|
+
return readdirSync(srcDir, { withFileTypes: true })
|
|
206
|
+
.filter((e) => e.isDirectory() && !e.name.startsWith('.'))
|
|
207
|
+
.map((e) => e.name)
|
|
208
|
+
.sort()
|
|
209
|
+
} catch {
|
|
210
|
+
return []
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/* ── Universal File Scanner ─────────────────────────────────────────── */
|
|
215
|
+
|
|
216
|
+
/** Map directory names to component categories. */
|
|
217
|
+
const DIR_CATEGORIES: Record<string, ComponentEntry['category']> = {
|
|
218
|
+
screens: 'screen',
|
|
219
|
+
pages: 'page',
|
|
220
|
+
views: 'view',
|
|
221
|
+
routes: 'page',
|
|
222
|
+
components: 'component',
|
|
223
|
+
widgets: 'widget',
|
|
224
|
+
ui: 'component',
|
|
225
|
+
atoms: 'component',
|
|
226
|
+
molecules: 'component',
|
|
227
|
+
organisms: 'component',
|
|
228
|
+
common: 'component',
|
|
229
|
+
shared: 'component',
|
|
230
|
+
layouts: 'layout',
|
|
231
|
+
templates: 'layout',
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const SKIP_DIRS = new Set([
|
|
235
|
+
'node_modules',
|
|
236
|
+
'__tests__',
|
|
237
|
+
'test',
|
|
238
|
+
'tests',
|
|
239
|
+
'build',
|
|
240
|
+
'dist',
|
|
241
|
+
'.dart_tool',
|
|
242
|
+
'android',
|
|
243
|
+
'ios',
|
|
244
|
+
'web',
|
|
245
|
+
'macos',
|
|
246
|
+
'windows',
|
|
247
|
+
'linux',
|
|
248
|
+
'.next',
|
|
249
|
+
'.nuxt',
|
|
250
|
+
'.svelte-kit',
|
|
251
|
+
'.astro',
|
|
252
|
+
'coverage',
|
|
253
|
+
'.git',
|
|
254
|
+
])
|
|
255
|
+
|
|
256
|
+
const SKIP_FILE_PATTERNS = /\.(test|spec|stories|d)\.[^.]+$/
|
|
257
|
+
|
|
258
|
+
/** Determine source roots for a given framework. */
|
|
259
|
+
function getSourceRoots(
|
|
260
|
+
projectRoot: string,
|
|
261
|
+
framework: string | null,
|
|
262
|
+
): string[] {
|
|
263
|
+
const roots: string[] = []
|
|
264
|
+
const candidates =
|
|
265
|
+
framework === 'flutter'
|
|
266
|
+
? ['lib']
|
|
267
|
+
: ['src', 'app', 'components', 'pages', 'views', 'screens', 'lib']
|
|
268
|
+
|
|
269
|
+
for (const dir of candidates) {
|
|
270
|
+
const full = path.join(projectRoot, dir)
|
|
271
|
+
if (existsSync(full)) roots.push(full)
|
|
272
|
+
}
|
|
273
|
+
return roots
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Get file extension regex for framework source files. */
|
|
277
|
+
function getSourceExtensions(framework: string | null): RegExp {
|
|
278
|
+
switch (framework) {
|
|
279
|
+
case 'flutter':
|
|
280
|
+
return /\.dart$/
|
|
281
|
+
case 'svelte':
|
|
282
|
+
return /\.(svelte|ts|js)$/
|
|
283
|
+
case 'astro':
|
|
284
|
+
return /\.(astro|tsx|ts|jsx|js)$/
|
|
285
|
+
case 'vue':
|
|
286
|
+
case 'nuxt':
|
|
287
|
+
return /\.(vue|ts|js)$/
|
|
288
|
+
default:
|
|
289
|
+
return /\.(tsx|ts|jsx|js)$/
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Convert a filename to PascalCase component name. */
|
|
294
|
+
function fileNameToPascalCase(name: string): string {
|
|
295
|
+
// Strip extension
|
|
296
|
+
const base = name.replace(/\.[^.]+$/, '')
|
|
297
|
+
return base
|
|
298
|
+
.split(/[-_\s]+/)
|
|
299
|
+
.filter(Boolean)
|
|
300
|
+
.map((s) => s.charAt(0).toUpperCase() + s.slice(1))
|
|
301
|
+
.join('')
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** Find the category for a file path by checking ancestor directory names. */
|
|
305
|
+
function getCategoryFromPath(relPath: string): ComponentEntry['category'] {
|
|
306
|
+
const segments = relPath.split(path.sep)
|
|
307
|
+
// Walk from the file upward toward root, first match wins
|
|
308
|
+
for (let i = segments.length - 2; i >= 0; i--) {
|
|
309
|
+
const cat = DIR_CATEGORIES[segments[i].toLowerCase()]
|
|
310
|
+
if (cat) return cat
|
|
311
|
+
}
|
|
312
|
+
return 'component'
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Recursively walk a source directory collecting ComponentEntry items. */
|
|
316
|
+
function walkSourceDir(
|
|
317
|
+
dir: string,
|
|
318
|
+
sourceRoot: string,
|
|
319
|
+
projectRoot: string,
|
|
320
|
+
extensions: RegExp,
|
|
321
|
+
components: ComponentEntry[],
|
|
322
|
+
seenPaths: Set<string>,
|
|
323
|
+
depth: number,
|
|
324
|
+
): void {
|
|
325
|
+
if (depth > 6) return
|
|
326
|
+
let entries
|
|
327
|
+
try {
|
|
328
|
+
entries = readdirSync(dir, { withFileTypes: true })
|
|
329
|
+
} catch {
|
|
330
|
+
return
|
|
331
|
+
}
|
|
332
|
+
for (const entry of entries) {
|
|
333
|
+
if (entry.name.startsWith('.')) continue
|
|
334
|
+
|
|
335
|
+
const fullPath = path.join(dir, entry.name)
|
|
336
|
+
|
|
337
|
+
if (entry.isDirectory()) {
|
|
338
|
+
if (SKIP_DIRS.has(entry.name)) continue
|
|
339
|
+
walkSourceDir(
|
|
340
|
+
fullPath,
|
|
341
|
+
sourceRoot,
|
|
342
|
+
projectRoot,
|
|
343
|
+
extensions,
|
|
344
|
+
components,
|
|
345
|
+
seenPaths,
|
|
346
|
+
depth + 1,
|
|
347
|
+
)
|
|
348
|
+
} else if (entry.isFile() && extensions.test(entry.name)) {
|
|
349
|
+
if (SKIP_FILE_PATTERNS.test(entry.name)) continue
|
|
350
|
+
const relPath = path.relative(projectRoot, fullPath)
|
|
351
|
+
if (seenPaths.has(relPath)) continue
|
|
352
|
+
seenPaths.add(relPath)
|
|
353
|
+
|
|
354
|
+
// For index files, use parent dir name
|
|
355
|
+
const baseName = entry.name.replace(/\.[^.]+$/, '')
|
|
356
|
+
const isIndex = baseName === 'index' || baseName === 'main'
|
|
357
|
+
const nameSource = isIndex ? path.basename(dir) : entry.name
|
|
358
|
+
const name = fileNameToPascalCase(nameSource)
|
|
359
|
+
if (!name) continue
|
|
360
|
+
|
|
361
|
+
const category = getCategoryFromPath(relPath)
|
|
362
|
+
components.push({
|
|
363
|
+
name,
|
|
364
|
+
filePath: relPath,
|
|
365
|
+
nameLower: name.toLowerCase(),
|
|
366
|
+
category,
|
|
367
|
+
})
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** Scan all source files in the project and categorize them. */
|
|
373
|
+
function scanAllSourceFiles(
|
|
374
|
+
projectRoot: string,
|
|
375
|
+
framework: string | null,
|
|
376
|
+
): ComponentEntry[] {
|
|
377
|
+
const sourceRoots = getSourceRoots(projectRoot, framework)
|
|
378
|
+
const extensions = getSourceExtensions(framework)
|
|
379
|
+
const components: ComponentEntry[] = []
|
|
380
|
+
const seenPaths = new Set<string>()
|
|
381
|
+
|
|
382
|
+
for (const root of sourceRoots) {
|
|
383
|
+
walkSourceDir(root, root, projectRoot, extensions, components, seenPaths, 0)
|
|
384
|
+
}
|
|
385
|
+
return components
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Detect the routing convention used in an app/ directory.
|
|
390
|
+
* Returns 'nextjs' (page.tsx), 'index' (index.tsx — Expo, SvelteKit, etc.), or null.
|
|
391
|
+
*/
|
|
392
|
+
function detectRoutingConvention(appDir: string): 'nextjs' | 'index' | null {
|
|
393
|
+
try {
|
|
394
|
+
const entries = readdirSync(appDir, { withFileTypes: true })
|
|
395
|
+
for (const entry of entries) {
|
|
396
|
+
if (!entry.isFile()) continue
|
|
397
|
+
if (/^page\.(tsx|ts|jsx|js)$/.test(entry.name)) return 'nextjs'
|
|
398
|
+
if (/^index\.(tsx|ts|jsx|js|svelte|astro|vue)$/.test(entry.name))
|
|
399
|
+
return 'index'
|
|
400
|
+
}
|
|
401
|
+
// Check one level deeper (e.g. app/(group)/page.tsx)
|
|
402
|
+
for (const entry of entries) {
|
|
403
|
+
if (!entry.isDirectory()) continue
|
|
404
|
+
try {
|
|
405
|
+
const subEntries = readdirSync(path.join(appDir, entry.name), {
|
|
406
|
+
withFileTypes: true,
|
|
407
|
+
})
|
|
408
|
+
for (const sub of subEntries) {
|
|
409
|
+
if (!sub.isFile()) continue
|
|
410
|
+
if (/^page\.(tsx|ts|jsx|js)$/.test(sub.name)) return 'nextjs'
|
|
411
|
+
if (/^index\.(tsx|ts|jsx|js|svelte|astro|vue)$/.test(sub.name))
|
|
412
|
+
return 'index'
|
|
413
|
+
}
|
|
414
|
+
} catch {
|
|
415
|
+
/* ignore */
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
} catch {
|
|
419
|
+
/* ignore */
|
|
420
|
+
}
|
|
421
|
+
return null
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** Source file extensions for route scanning (broader than framework-specific). */
|
|
425
|
+
const ROUTE_SOURCE_EXT = /\.(tsx|ts|jsx|js|svelte|astro|vue|dart)$/
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Universal file-based route scanner.
|
|
429
|
+
* Auto-detects routing convention by inspecting the app/ directory.
|
|
430
|
+
* Works for Next.js, Expo Router, SvelteKit, Astro, Nuxt, and any
|
|
431
|
+
* framework using file-based routing in an app/ or src/app/ directory.
|
|
432
|
+
*/
|
|
433
|
+
function scanAppRoutes(projectRoot: string): RouteEntry[] {
|
|
434
|
+
const routes: RouteEntry[] = []
|
|
435
|
+
const maybeAppDir = existsSync(path.join(projectRoot, 'src', 'app'))
|
|
436
|
+
? path.join(projectRoot, 'src', 'app')
|
|
437
|
+
: existsSync(path.join(projectRoot, 'app'))
|
|
438
|
+
? path.join(projectRoot, 'app')
|
|
439
|
+
: null
|
|
440
|
+
|
|
441
|
+
if (!maybeAppDir) return routes
|
|
442
|
+
const appDir: string = maybeAppDir
|
|
443
|
+
|
|
444
|
+
const convention = detectRoutingConvention(appDir)
|
|
445
|
+
if (!convention) return routes
|
|
446
|
+
|
|
447
|
+
// Build regex based on detected convention
|
|
448
|
+
const routeFilePattern =
|
|
449
|
+
convention === 'nextjs'
|
|
450
|
+
? /^(page|layout|loading|error|not-found|template)\.(tsx|ts|jsx|js|svelte|astro|vue)$/
|
|
451
|
+
: /^(index|_layout)\.(tsx|ts|jsx|js|svelte|astro|vue)$/
|
|
452
|
+
|
|
453
|
+
function walkAppDir(dir: string, depth: number): void {
|
|
454
|
+
if (depth > 8) return
|
|
455
|
+
let entries
|
|
456
|
+
try {
|
|
457
|
+
entries = readdirSync(dir, { withFileTypes: true })
|
|
458
|
+
} catch {
|
|
459
|
+
return
|
|
460
|
+
}
|
|
461
|
+
for (const entry of entries) {
|
|
462
|
+
if (entry.name.startsWith('.')) continue
|
|
463
|
+
if (entry.name.startsWith('_') && entry.isDirectory()) continue
|
|
464
|
+
const fullPath = path.join(dir, entry.name)
|
|
465
|
+
|
|
466
|
+
if (entry.isDirectory()) {
|
|
467
|
+
if (entry.name === 'api' || entry.name.startsWith('@')) continue
|
|
468
|
+
walkAppDir(fullPath, depth + 1)
|
|
469
|
+
} else if (entry.isFile()) {
|
|
470
|
+
const match = entry.name.match(routeFilePattern)
|
|
471
|
+
if (!match) continue
|
|
472
|
+
|
|
473
|
+
const rawType = match[1]
|
|
474
|
+
const type: RouteEntry['type'] =
|
|
475
|
+
rawType === 'index'
|
|
476
|
+
? 'page'
|
|
477
|
+
: rawType === '_layout'
|
|
478
|
+
? 'layout'
|
|
479
|
+
: (rawType as RouteEntry['type'])
|
|
480
|
+
|
|
481
|
+
const relDir = path.relative(appDir, dir)
|
|
482
|
+
const urlSegments = relDir
|
|
483
|
+
.split(path.sep)
|
|
484
|
+
.filter(Boolean)
|
|
485
|
+
.filter((seg) => !seg.startsWith('('))
|
|
486
|
+
|
|
487
|
+
const urlPattern = '/' + urlSegments.join('/')
|
|
488
|
+
const filePath = path.relative(projectRoot, fullPath)
|
|
489
|
+
|
|
490
|
+
routes.push({
|
|
491
|
+
urlPattern: urlPattern === '/' ? '/' : urlPattern.replace(/\/$/, ''),
|
|
492
|
+
filePath,
|
|
493
|
+
type,
|
|
494
|
+
})
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
walkAppDir(appDir, 0)
|
|
500
|
+
|
|
501
|
+
// For index-based conventions, also treat top-level source files as routes
|
|
502
|
+
// e.g. app/about.tsx → /about, app/settings.tsx → /settings
|
|
503
|
+
if (convention === 'index') {
|
|
504
|
+
try {
|
|
505
|
+
const topEntries = readdirSync(appDir, { withFileTypes: true })
|
|
506
|
+
for (const entry of topEntries) {
|
|
507
|
+
if (!entry.isFile()) continue
|
|
508
|
+
if (
|
|
509
|
+
entry.name.startsWith('_') ||
|
|
510
|
+
entry.name.startsWith('.') ||
|
|
511
|
+
entry.name.startsWith('+')
|
|
512
|
+
)
|
|
513
|
+
continue
|
|
514
|
+
if (!ROUTE_SOURCE_EXT.test(entry.name)) continue
|
|
515
|
+
const baseName = entry.name.replace(ROUTE_SOURCE_EXT, '')
|
|
516
|
+
if (baseName === 'index' || baseName === '_layout') continue
|
|
517
|
+
const fullPath = path.join(appDir, entry.name)
|
|
518
|
+
const filePath = path.relative(projectRoot, fullPath)
|
|
519
|
+
routes.push({ urlPattern: `/${baseName}`, filePath, type: 'page' })
|
|
520
|
+
}
|
|
521
|
+
} catch {
|
|
522
|
+
/* ignore */
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
return routes
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* For non-web frameworks (Flutter, etc.), scan screen/page directories
|
|
531
|
+
* and synthesize route-like entries from the file structure.
|
|
532
|
+
* e.g. lib/screens/home_screen.dart → RouteEntry { urlPattern: '/', type: 'page' }
|
|
533
|
+
*/
|
|
534
|
+
function synthesizeRoutesFromComponents(
|
|
535
|
+
components: ComponentEntry[],
|
|
536
|
+
): RouteEntry[] {
|
|
537
|
+
const routes: RouteEntry[] = []
|
|
538
|
+
const seenUrls = new Set<string>()
|
|
539
|
+
|
|
540
|
+
for (const comp of components) {
|
|
541
|
+
if (
|
|
542
|
+
comp.category !== 'screen' &&
|
|
543
|
+
comp.category !== 'page' &&
|
|
544
|
+
comp.category !== 'view'
|
|
545
|
+
)
|
|
546
|
+
continue
|
|
547
|
+
|
|
548
|
+
// Derive a URL from the component name
|
|
549
|
+
// HomeScreen → /, IndexScreen → /, SettingsScreen → /settings
|
|
550
|
+
let nameLower = comp.nameLower
|
|
551
|
+
// Strip common suffixes
|
|
552
|
+
for (const suffix of ['screen', 'page', 'view', 'widget']) {
|
|
553
|
+
if (nameLower.endsWith(suffix) && nameLower.length > suffix.length) {
|
|
554
|
+
nameLower = nameLower.slice(0, -suffix.length)
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
const urlPattern =
|
|
559
|
+
nameLower === 'home' ||
|
|
560
|
+
nameLower === 'index' ||
|
|
561
|
+
nameLower === 'main' ||
|
|
562
|
+
nameLower === 'app'
|
|
563
|
+
? '/'
|
|
564
|
+
: `/${nameLower}`
|
|
565
|
+
|
|
566
|
+
if (seenUrls.has(urlPattern)) continue
|
|
567
|
+
seenUrls.add(urlPattern)
|
|
568
|
+
|
|
569
|
+
routes.push({ urlPattern, filePath: comp.filePath, type: 'page' })
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
return routes
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/** Scan routes universally — tries file-based routing first, then synthesizes from components. */
|
|
576
|
+
function scanRoutes(
|
|
577
|
+
projectRoot: string,
|
|
578
|
+
components: ComponentEntry[],
|
|
579
|
+
): RouteEntry[] {
|
|
580
|
+
const fileRoutes = scanAppRoutes(projectRoot)
|
|
581
|
+
if (fileRoutes.length > 0) return fileRoutes
|
|
582
|
+
// No file-based routes found — synthesize from screen/page components
|
|
583
|
+
return synthesizeRoutesFromComponents(components)
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/** Build the complete FileMap for a project. */
|
|
587
|
+
function buildFileMap(projectRoot: string, framework: string | null): FileMap {
|
|
588
|
+
const components = scanAllSourceFiles(projectRoot, framework)
|
|
589
|
+
return {
|
|
590
|
+
routes: scanRoutes(projectRoot, components),
|
|
591
|
+
components,
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* POST /api/project/scan
|
|
597
|
+
* Scans a project folder and returns framework, CSS strategy, key files, and structure.
|
|
598
|
+
*/
|
|
599
|
+
export async function POST(request: Request): Promise<NextResponse> {
|
|
600
|
+
let body: { projectRoot: string }
|
|
601
|
+
try {
|
|
602
|
+
body = await request.json()
|
|
603
|
+
} catch {
|
|
604
|
+
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
const { projectRoot } = body
|
|
608
|
+
if (!projectRoot || typeof projectRoot !== 'string') {
|
|
609
|
+
return NextResponse.json(
|
|
610
|
+
{ error: 'projectRoot is required' },
|
|
611
|
+
{ status: 400 },
|
|
612
|
+
)
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
const validationError = validateProjectRoot(projectRoot)
|
|
616
|
+
if (validationError) {
|
|
617
|
+
return NextResponse.json({ error: validationError }, { status: 400 })
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
const resolved = path.resolve(projectRoot)
|
|
621
|
+
const pkg = readPackageJson(resolved)
|
|
622
|
+
const framework = detectFramework(resolved, pkg)
|
|
623
|
+
|
|
624
|
+
const result: ProjectScanResult = {
|
|
625
|
+
framework,
|
|
626
|
+
cssStrategy: detectCssStrategy(resolved, pkg),
|
|
627
|
+
cssFiles: findCssFiles(resolved),
|
|
628
|
+
srcDirs: listSrcDirs(resolved),
|
|
629
|
+
packageName: (pkg?.name as string) ?? null,
|
|
630
|
+
fileMap: buildFileMap(resolved, framework),
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
return NextResponse.json(result)
|
|
634
|
+
}
|