@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.
Files changed (158) hide show
  1. package/LICENSE +178 -0
  2. package/NOTICE +4 -0
  3. package/README.md +180 -0
  4. package/bin/paint.js +266 -0
  5. package/next-env.d.ts +6 -0
  6. package/next.config.ts +19 -0
  7. package/package.json +81 -0
  8. package/postcss.config.mjs +8 -0
  9. package/public/dev-editor-inspector.js +1872 -0
  10. package/src/app/api/claude/analyze/route.ts +319 -0
  11. package/src/app/api/claude/apply/route.ts +185 -0
  12. package/src/app/api/claude/pick-folder/route.ts +64 -0
  13. package/src/app/api/claude/scan/route.ts +221 -0
  14. package/src/app/api/claude/status/route.ts +55 -0
  15. package/src/app/api/project/scan/route.ts +634 -0
  16. package/src/app/api/project-scan/css-variables/route.ts +238 -0
  17. package/src/app/api/project-scan/route.ts +40 -0
  18. package/src/app/api/project-scan/tailwind-config/route.ts +172 -0
  19. package/src/app/api/proxy/[[...path]]/route.ts +2400 -0
  20. package/src/app/docs/DocsClient.tsx +322 -0
  21. package/src/app/docs/layout.tsx +7 -0
  22. package/src/app/docs/page.tsx +855 -0
  23. package/src/app/globals.css +176 -0
  24. package/src/app/layout.tsx +19 -0
  25. package/src/app/page.tsx +46 -0
  26. package/src/bridge/api-handlers.ts +885 -0
  27. package/src/bridge/proxy-handler.ts +329 -0
  28. package/src/bridge/server.ts +113 -0
  29. package/src/components/BreakpointTabs.tsx +72 -0
  30. package/src/components/ChangeSummaryModal.tsx +267 -0
  31. package/src/components/ConnectModal.tsx +994 -0
  32. package/src/components/Editor.tsx +90 -0
  33. package/src/components/PageSelector.tsx +208 -0
  34. package/src/components/PreviewFrame.tsx +299 -0
  35. package/src/components/ProjectFolderBanner.tsx +91 -0
  36. package/src/components/ResponsiveToolbar.tsx +222 -0
  37. package/src/components/TargetSelector.tsx +243 -0
  38. package/src/components/TopBar.tsx +315 -0
  39. package/src/components/common/CollapsibleSection.tsx +36 -0
  40. package/src/components/common/ColorPicker.tsx +920 -0
  41. package/src/components/common/EditablePre.tsx +136 -0
  42. package/src/components/common/ErrorBoundary.tsx +65 -0
  43. package/src/components/common/ResizablePanel.tsx +83 -0
  44. package/src/components/common/ScanAnimation.tsx +76 -0
  45. package/src/components/common/ToastContainer.tsx +97 -0
  46. package/src/components/common/UnitInput.tsx +77 -0
  47. package/src/components/common/VariableColorPicker.tsx +622 -0
  48. package/src/components/left-panel/AddElementPanel.tsx +237 -0
  49. package/src/components/left-panel/ComponentsPanel.tsx +609 -0
  50. package/src/components/left-panel/IconSidebar.tsx +99 -0
  51. package/src/components/left-panel/LayerNode.tsx +874 -0
  52. package/src/components/left-panel/LayerSearch.tsx +23 -0
  53. package/src/components/left-panel/LayersPanel.tsx +52 -0
  54. package/src/components/left-panel/LeftPanel.tsx +122 -0
  55. package/src/components/left-panel/PagesPanel.tsx +114 -0
  56. package/src/components/left-panel/icons.tsx +162 -0
  57. package/src/components/left-panel/terminal/ScanOverlay.tsx +66 -0
  58. package/src/components/left-panel/terminal/TerminalPanel.tsx +217 -0
  59. package/src/components/right-panel/ElementLogBox.tsx +248 -0
  60. package/src/components/right-panel/PanelTabs.tsx +83 -0
  61. package/src/components/right-panel/RightPanel.tsx +41 -0
  62. package/src/components/right-panel/changes/AiScanResultPanel.tsx +285 -0
  63. package/src/components/right-panel/changes/ChangeEntry.tsx +59 -0
  64. package/src/components/right-panel/changes/ChangelogActions.tsx +105 -0
  65. package/src/components/right-panel/changes/ChangesPanel.tsx +1474 -0
  66. package/src/components/right-panel/claude/ApplyConfirmModal.tsx +376 -0
  67. package/src/components/right-panel/claude/ClaudeErrorState.tsx +125 -0
  68. package/src/components/right-panel/claude/ClaudeIntegrationPanel.tsx +482 -0
  69. package/src/components/right-panel/claude/ClaudeProgressIndicator.tsx +76 -0
  70. package/src/components/right-panel/claude/DiffCard.tsx +130 -0
  71. package/src/components/right-panel/claude/DiffViewer.tsx +54 -0
  72. package/src/components/right-panel/claude/ProjectRootSelector.tsx +275 -0
  73. package/src/components/right-panel/claude/ResultsSummary.tsx +119 -0
  74. package/src/components/right-panel/claude/SetupFlow.tsx +315 -0
  75. package/src/components/right-panel/console/ConsolePanel.tsx +209 -0
  76. package/src/components/right-panel/design/AppearanceSection.tsx +703 -0
  77. package/src/components/right-panel/design/BackgroundSection.tsx +516 -0
  78. package/src/components/right-panel/design/BorderSection.tsx +161 -0
  79. package/src/components/right-panel/design/CSSRawView.tsx +412 -0
  80. package/src/components/right-panel/design/DesignCSSTabToggle.tsx +51 -0
  81. package/src/components/right-panel/design/DesignPanel.tsx +275 -0
  82. package/src/components/right-panel/design/ElementBreadcrumb.tsx +51 -0
  83. package/src/components/right-panel/design/GradientEditor.tsx +726 -0
  84. package/src/components/right-panel/design/LayoutSection.tsx +1948 -0
  85. package/src/components/right-panel/design/PositionSection.tsx +865 -0
  86. package/src/components/right-panel/design/PropertiesSection.tsx +86 -0
  87. package/src/components/right-panel/design/SVGSection.tsx +361 -0
  88. package/src/components/right-panel/design/ShadowBlurSection.tsx +227 -0
  89. package/src/components/right-panel/design/SizeSection.tsx +183 -0
  90. package/src/components/right-panel/design/TextSection.tsx +719 -0
  91. package/src/components/right-panel/design/icons.tsx +948 -0
  92. package/src/components/right-panel/design/inputs/BoxModelPreview.tsx +467 -0
  93. package/src/components/right-panel/design/inputs/ColorInput.tsx +43 -0
  94. package/src/components/right-panel/design/inputs/CompactInput.tsx +333 -0
  95. package/src/components/right-panel/design/inputs/DraggableLabel.tsx +118 -0
  96. package/src/components/right-panel/design/inputs/IconToggleGroup.tsx +54 -0
  97. package/src/components/right-panel/design/inputs/LinkedInputPair.tsx +174 -0
  98. package/src/components/right-panel/design/inputs/SectionHeader.tsx +79 -0
  99. package/src/components/right-panel/variables/VariablesPanel.tsx +388 -0
  100. package/src/hooks/useBridge.ts +95 -0
  101. package/src/hooks/useChangeTracker.ts +563 -0
  102. package/src/hooks/useClaudeAPI.ts +118 -0
  103. package/src/hooks/useDOMTree.ts +25 -0
  104. package/src/hooks/useKeyboardShortcuts.ts +76 -0
  105. package/src/hooks/usePostMessage.ts +589 -0
  106. package/src/hooks/useProjectScan.ts +204 -0
  107. package/src/hooks/useResizable.ts +20 -0
  108. package/src/hooks/useSelectedElement.ts +51 -0
  109. package/src/hooks/useTargetUrl.ts +81 -0
  110. package/src/inspector/DOMTraverser.ts +71 -0
  111. package/src/inspector/ElementSelector.ts +23 -0
  112. package/src/inspector/HoverHighlighter.ts +54 -0
  113. package/src/inspector/SelectionHighlighter.ts +27 -0
  114. package/src/inspector/StyleExtractor.ts +19 -0
  115. package/src/inspector/inspector.ts +17 -0
  116. package/src/inspector/messaging.ts +30 -0
  117. package/src/lib/apiBase.ts +15 -0
  118. package/src/lib/classifyElement.ts +430 -0
  119. package/src/lib/claude-bin.ts +197 -0
  120. package/src/lib/claude-stream.ts +158 -0
  121. package/src/lib/clientProjectScanner.ts +344 -0
  122. package/src/lib/componentMatcher.ts +156 -0
  123. package/src/lib/constants.ts +573 -0
  124. package/src/lib/cssVariableUtils.ts +409 -0
  125. package/src/lib/diffParser.ts +206 -0
  126. package/src/lib/folderPicker.ts +84 -0
  127. package/src/lib/gradientParser.ts +160 -0
  128. package/src/lib/projectScanner.ts +355 -0
  129. package/src/lib/promptBuilder.ts +402 -0
  130. package/src/lib/shadowParser.ts +124 -0
  131. package/src/lib/tailwindClassParser.ts +248 -0
  132. package/src/lib/textShadowUtils.ts +106 -0
  133. package/src/lib/utils.ts +299 -0
  134. package/src/lib/validatePath.ts +40 -0
  135. package/src/proxy.ts +92 -0
  136. package/src/server/terminal-server.ts +104 -0
  137. package/src/store/changeSlice.ts +288 -0
  138. package/src/store/claudeSlice.ts +222 -0
  139. package/src/store/componentSlice.ts +90 -0
  140. package/src/store/consoleSlice.ts +51 -0
  141. package/src/store/cssVariableSlice.ts +94 -0
  142. package/src/store/elementSlice.ts +78 -0
  143. package/src/store/index.ts +35 -0
  144. package/src/store/terminalSlice.ts +30 -0
  145. package/src/store/treeSlice.ts +69 -0
  146. package/src/store/uiSlice.ts +327 -0
  147. package/src/types/changelog.ts +49 -0
  148. package/src/types/claude.ts +131 -0
  149. package/src/types/component.ts +49 -0
  150. package/src/types/cssVariables.ts +18 -0
  151. package/src/types/element.ts +21 -0
  152. package/src/types/file-system-access.d.ts +27 -0
  153. package/src/types/gradient.ts +12 -0
  154. package/src/types/messages.ts +392 -0
  155. package/src/types/shadow.ts +8 -0
  156. package/src/types/tree.ts +9 -0
  157. package/tsconfig.json +42 -0
  158. 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
+ }