@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,160 @@
|
|
|
1
|
+
import type { GradientData, GradientStop } from '@/types/gradient'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Split a CSS function argument string by commas, respecting nested parentheses.
|
|
5
|
+
*/
|
|
6
|
+
function splitArgs(str: string): string[] {
|
|
7
|
+
const parts: string[] = []
|
|
8
|
+
let depth = 0
|
|
9
|
+
let current = ''
|
|
10
|
+
for (let i = 0; i < str.length; i++) {
|
|
11
|
+
const ch = str[i]
|
|
12
|
+
if (ch === '(') depth++
|
|
13
|
+
else if (ch === ')') depth--
|
|
14
|
+
if (ch === ',' && depth === 0) {
|
|
15
|
+
parts.push(current.trim())
|
|
16
|
+
current = ''
|
|
17
|
+
} else {
|
|
18
|
+
current += ch
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
if (current.trim()) parts.push(current.trim())
|
|
22
|
+
return parts
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Parse a single gradient stop string like "red 50%" or "rgba(0,0,0,0.5) 25%"
|
|
27
|
+
*/
|
|
28
|
+
function parseStop(stopStr: string): GradientStop | null {
|
|
29
|
+
const trimmed = stopStr.trim()
|
|
30
|
+
// Try to match a position percentage at the end
|
|
31
|
+
const posMatch = trimmed.match(/\s+(\d+(?:\.\d+)?%?)$/)
|
|
32
|
+
let color: string
|
|
33
|
+
let position = 0
|
|
34
|
+
|
|
35
|
+
if (posMatch) {
|
|
36
|
+
color = trimmed.slice(0, trimmed.length - posMatch[0].length).trim()
|
|
37
|
+
const posVal = posMatch[1]
|
|
38
|
+
position = parseFloat(posVal)
|
|
39
|
+
} else {
|
|
40
|
+
color = trimmed
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (!color) return null
|
|
44
|
+
|
|
45
|
+
return { color, position, opacity: 1 }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Parse a CSS gradient string into a GradientData object.
|
|
50
|
+
* Supports both regular and repeating variants.
|
|
51
|
+
*/
|
|
52
|
+
export function parseGradient(css: string): GradientData | null {
|
|
53
|
+
if (!css) return null
|
|
54
|
+
const trimmed = css.trim()
|
|
55
|
+
|
|
56
|
+
let type: 'linear' | 'radial' | 'conic'
|
|
57
|
+
let inner: string
|
|
58
|
+
let repeat = false
|
|
59
|
+
|
|
60
|
+
if (trimmed.startsWith('repeating-linear-gradient(')) {
|
|
61
|
+
type = 'linear'
|
|
62
|
+
repeat = true
|
|
63
|
+
inner = trimmed.slice('repeating-linear-gradient('.length, -1)
|
|
64
|
+
} else if (trimmed.startsWith('repeating-radial-gradient(')) {
|
|
65
|
+
type = 'radial'
|
|
66
|
+
repeat = true
|
|
67
|
+
inner = trimmed.slice('repeating-radial-gradient('.length, -1)
|
|
68
|
+
} else if (trimmed.startsWith('repeating-conic-gradient(')) {
|
|
69
|
+
type = 'conic'
|
|
70
|
+
repeat = true
|
|
71
|
+
inner = trimmed.slice('repeating-conic-gradient('.length, -1)
|
|
72
|
+
} else if (trimmed.startsWith('linear-gradient(')) {
|
|
73
|
+
type = 'linear'
|
|
74
|
+
inner = trimmed.slice('linear-gradient('.length, -1)
|
|
75
|
+
} else if (trimmed.startsWith('radial-gradient(')) {
|
|
76
|
+
type = 'radial'
|
|
77
|
+
inner = trimmed.slice('radial-gradient('.length, -1)
|
|
78
|
+
} else if (trimmed.startsWith('conic-gradient(')) {
|
|
79
|
+
type = 'conic'
|
|
80
|
+
inner = trimmed.slice('conic-gradient('.length, -1)
|
|
81
|
+
} else {
|
|
82
|
+
return null
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const args = splitArgs(inner)
|
|
86
|
+
if (args.length < 2) return null
|
|
87
|
+
|
|
88
|
+
let angle = 180
|
|
89
|
+
let stopStartIndex = 0
|
|
90
|
+
|
|
91
|
+
// Check if first arg is an angle
|
|
92
|
+
const firstArg = args[0].trim()
|
|
93
|
+
const angleMatch = firstArg.match(/^(\d+(?:\.\d+)?)deg$/)
|
|
94
|
+
if (angleMatch) {
|
|
95
|
+
angle = parseFloat(angleMatch[1])
|
|
96
|
+
stopStartIndex = 1
|
|
97
|
+
} else if (firstArg.startsWith('from ')) {
|
|
98
|
+
const fromMatch = firstArg.match(/^from\s+(\d+(?:\.\d+)?)deg$/)
|
|
99
|
+
if (fromMatch) {
|
|
100
|
+
angle = parseFloat(fromMatch[1])
|
|
101
|
+
stopStartIndex = 1
|
|
102
|
+
}
|
|
103
|
+
} else if (firstArg === 'to top') {
|
|
104
|
+
angle = 0
|
|
105
|
+
stopStartIndex = 1
|
|
106
|
+
} else if (firstArg === 'to right') {
|
|
107
|
+
angle = 90
|
|
108
|
+
stopStartIndex = 1
|
|
109
|
+
} else if (firstArg === 'to bottom') {
|
|
110
|
+
angle = 180
|
|
111
|
+
stopStartIndex = 1
|
|
112
|
+
} else if (firstArg === 'to left') {
|
|
113
|
+
angle = 270
|
|
114
|
+
stopStartIndex = 1
|
|
115
|
+
} else if (firstArg.startsWith('to ')) {
|
|
116
|
+
stopStartIndex = 1
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const stops: GradientStop[] = []
|
|
120
|
+
const stopArgs = args.slice(stopStartIndex)
|
|
121
|
+
|
|
122
|
+
for (let i = 0; i < stopArgs.length; i++) {
|
|
123
|
+
const stop = parseStop(stopArgs[i])
|
|
124
|
+
if (stop) {
|
|
125
|
+
// Auto-distribute positions if not specified
|
|
126
|
+
if (stop.position === 0 && i > 0) {
|
|
127
|
+
stop.position = Math.round((i / (stopArgs.length - 1)) * 100)
|
|
128
|
+
}
|
|
129
|
+
if (i === 0 && stop.position === 0) {
|
|
130
|
+
stop.position = 0
|
|
131
|
+
}
|
|
132
|
+
if (i === stopArgs.length - 1 && stop.position === 0) {
|
|
133
|
+
stop.position = 100
|
|
134
|
+
}
|
|
135
|
+
stops.push(stop)
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (stops.length < 2) return null
|
|
140
|
+
|
|
141
|
+
return { type, angle, stops, repeat }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Serialize a GradientData object back to a valid CSS gradient string.
|
|
146
|
+
* Supports repeating variants when data.repeat is true.
|
|
147
|
+
*/
|
|
148
|
+
export function serializeGradient(data: GradientData): string {
|
|
149
|
+
const stopStrs = data.stops.map((s) => `${s.color} ${s.position}%`)
|
|
150
|
+
const prefix = data.repeat ? 'repeating-' : ''
|
|
151
|
+
|
|
152
|
+
switch (data.type) {
|
|
153
|
+
case 'linear':
|
|
154
|
+
return `${prefix}linear-gradient(${data.angle}deg, ${stopStrs.join(', ')})`
|
|
155
|
+
case 'radial':
|
|
156
|
+
return `${prefix}radial-gradient(${stopStrs.join(', ')})`
|
|
157
|
+
case 'conic':
|
|
158
|
+
return `${prefix}conic-gradient(from ${data.angle}deg, ${stopStrs.join(', ')})`
|
|
159
|
+
}
|
|
160
|
+
}
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared project scanner logic.
|
|
3
|
+
* Used by both the Next.js /api/project-scan route and the bridge server.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
7
|
+
import path from 'node:path'
|
|
8
|
+
import type { RouteEntry } from '@/types/claude'
|
|
9
|
+
|
|
10
|
+
export interface ProjectScanData {
|
|
11
|
+
projectName: string
|
|
12
|
+
componentCount: number
|
|
13
|
+
componentFileMap: Record<string, string>
|
|
14
|
+
framework: string | null
|
|
15
|
+
cssStrategy: string[]
|
|
16
|
+
cssFiles: string[]
|
|
17
|
+
srcDirs: string[]
|
|
18
|
+
assetDirs: string[]
|
|
19
|
+
routes: RouteEntry[]
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const SKIP_DIRS = new Set([
|
|
23
|
+
'node_modules',
|
|
24
|
+
'.next',
|
|
25
|
+
'dist',
|
|
26
|
+
'build',
|
|
27
|
+
'.git',
|
|
28
|
+
'__tests__',
|
|
29
|
+
'__mocks__',
|
|
30
|
+
'.turbo',
|
|
31
|
+
'.vercel',
|
|
32
|
+
'coverage',
|
|
33
|
+
])
|
|
34
|
+
|
|
35
|
+
const COMPONENT_EXTENSIONS = new Set(['.tsx', '.jsx'])
|
|
36
|
+
const MAYBE_COMPONENT_EXTENSIONS = new Set(['.ts', '.js'])
|
|
37
|
+
const CSS_EXTENSIONS = new Set(['.css', '.scss', '.sass', '.less'])
|
|
38
|
+
const MAX_FILES = 5000
|
|
39
|
+
|
|
40
|
+
const CONVENTION_FILES = new Set([
|
|
41
|
+
'page',
|
|
42
|
+
'layout',
|
|
43
|
+
'loading',
|
|
44
|
+
'error',
|
|
45
|
+
'not-found',
|
|
46
|
+
'template',
|
|
47
|
+
'default',
|
|
48
|
+
'route',
|
|
49
|
+
'proxy',
|
|
50
|
+
'middleware',
|
|
51
|
+
'global-error',
|
|
52
|
+
'instrumentation',
|
|
53
|
+
])
|
|
54
|
+
|
|
55
|
+
const ROUTE_CONVENTION_MAP: Record<string, RouteEntry['type']> = {
|
|
56
|
+
page: 'page',
|
|
57
|
+
layout: 'layout',
|
|
58
|
+
loading: 'loading',
|
|
59
|
+
error: 'error',
|
|
60
|
+
'not-found': 'not-found',
|
|
61
|
+
template: 'template',
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const PREFERRED_SEGMENTS = new Set(['components', 'ui', 'common', 'shared'])
|
|
65
|
+
|
|
66
|
+
const ASSET_DIR_NAMES = new Set([
|
|
67
|
+
'images',
|
|
68
|
+
'img',
|
|
69
|
+
'fonts',
|
|
70
|
+
'icons',
|
|
71
|
+
'media',
|
|
72
|
+
'assets',
|
|
73
|
+
'static',
|
|
74
|
+
'videos',
|
|
75
|
+
'svgs',
|
|
76
|
+
'illustrations',
|
|
77
|
+
])
|
|
78
|
+
|
|
79
|
+
function isPascalCase(name: string): boolean {
|
|
80
|
+
return /^[A-Z][a-zA-Z0-9]*$/.test(name)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function hasPreferredSegment(relativePath: string): boolean {
|
|
84
|
+
const segments = relativePath.split(path.sep)
|
|
85
|
+
return segments.some((s) => PREFERRED_SEGMENTS.has(s))
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function filePathToUrlPattern(relativePath: string): string {
|
|
89
|
+
const parts = relativePath.split(path.sep)
|
|
90
|
+
const routeParts = parts.slice(1, -1)
|
|
91
|
+
const filtered = routeParts.filter((p) => !p.startsWith('('))
|
|
92
|
+
return '/' + filtered.join('/')
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
interface WalkCollectors {
|
|
96
|
+
componentFileMap: Record<string, string>
|
|
97
|
+
cssFiles: string[]
|
|
98
|
+
routes: RouteEntry[]
|
|
99
|
+
assetDirs: Set<string>
|
|
100
|
+
hasCssModules: boolean
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function walkDir(
|
|
104
|
+
dir: string,
|
|
105
|
+
projectRoot: string,
|
|
106
|
+
collectors: WalkCollectors,
|
|
107
|
+
counter: { count: number },
|
|
108
|
+
): void {
|
|
109
|
+
if (counter.count >= MAX_FILES) return
|
|
110
|
+
|
|
111
|
+
let entries: import('node:fs').Dirent[]
|
|
112
|
+
try {
|
|
113
|
+
entries = readdirSync(dir, { withFileTypes: true })
|
|
114
|
+
} catch {
|
|
115
|
+
return
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
for (const entry of entries) {
|
|
119
|
+
if (counter.count >= MAX_FILES) return
|
|
120
|
+
if (SKIP_DIRS.has(entry.name)) continue
|
|
121
|
+
|
|
122
|
+
const fullPath = path.join(dir, entry.name)
|
|
123
|
+
|
|
124
|
+
if (entry.isDirectory()) {
|
|
125
|
+
if (ASSET_DIR_NAMES.has(entry.name.toLowerCase())) {
|
|
126
|
+
const relDir = path.relative(projectRoot, fullPath)
|
|
127
|
+
collectors.assetDirs.add(relDir)
|
|
128
|
+
}
|
|
129
|
+
walkDir(fullPath, projectRoot, collectors, counter)
|
|
130
|
+
continue
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (!entry.isFile()) continue
|
|
134
|
+
counter.count++
|
|
135
|
+
|
|
136
|
+
const ext = path.extname(entry.name)
|
|
137
|
+
const baseName = path.basename(entry.name, ext)
|
|
138
|
+
const relativePath = path.relative(projectRoot, fullPath)
|
|
139
|
+
|
|
140
|
+
if (baseName.startsWith('.')) continue
|
|
141
|
+
|
|
142
|
+
if (CSS_EXTENSIONS.has(ext)) {
|
|
143
|
+
collectors.cssFiles.push(relativePath)
|
|
144
|
+
if (entry.name.includes('.module.')) {
|
|
145
|
+
collectors.hasCssModules = true
|
|
146
|
+
}
|
|
147
|
+
continue
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (baseName.endsWith('.test') || baseName.endsWith('.spec')) continue
|
|
151
|
+
|
|
152
|
+
const routeType = ROUTE_CONVENTION_MAP[baseName]
|
|
153
|
+
if (
|
|
154
|
+
routeType &&
|
|
155
|
+
(COMPONENT_EXTENSIONS.has(ext) || MAYBE_COMPONENT_EXTENSIONS.has(ext))
|
|
156
|
+
) {
|
|
157
|
+
if (
|
|
158
|
+
relativePath.startsWith('app' + path.sep) ||
|
|
159
|
+
relativePath.startsWith('src' + path.sep + 'app' + path.sep)
|
|
160
|
+
) {
|
|
161
|
+
collectors.routes.push({
|
|
162
|
+
urlPattern: filePathToUrlPattern(
|
|
163
|
+
relativePath.startsWith('src' + path.sep)
|
|
164
|
+
? relativePath.slice(4)
|
|
165
|
+
: relativePath,
|
|
166
|
+
),
|
|
167
|
+
filePath: relativePath,
|
|
168
|
+
type: routeType,
|
|
169
|
+
})
|
|
170
|
+
}
|
|
171
|
+
continue
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (baseName === 'index') continue
|
|
175
|
+
if (CONVENTION_FILES.has(baseName)) continue
|
|
176
|
+
|
|
177
|
+
const isComponentExt = COMPONENT_EXTENSIONS.has(ext)
|
|
178
|
+
const isMaybeComponentExt = MAYBE_COMPONENT_EXTENSIONS.has(ext)
|
|
179
|
+
|
|
180
|
+
if (!isComponentExt && !isMaybeComponentExt) continue
|
|
181
|
+
if (isMaybeComponentExt && !isPascalCase(baseName)) continue
|
|
182
|
+
|
|
183
|
+
if (collectors.componentFileMap[baseName]) {
|
|
184
|
+
const existingPreferred = hasPreferredSegment(
|
|
185
|
+
collectors.componentFileMap[baseName],
|
|
186
|
+
)
|
|
187
|
+
const newPreferred = hasPreferredSegment(relativePath)
|
|
188
|
+
if (newPreferred && !existingPreferred) {
|
|
189
|
+
collectors.componentFileMap[baseName] = relativePath
|
|
190
|
+
}
|
|
191
|
+
} else {
|
|
192
|
+
collectors.componentFileMap[baseName] = relativePath
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function detectFramework(
|
|
198
|
+
deps: Record<string, string>,
|
|
199
|
+
devDeps: Record<string, string>,
|
|
200
|
+
): string | null {
|
|
201
|
+
const all = { ...deps, ...devDeps }
|
|
202
|
+
if (all['next']) return 'Next.js'
|
|
203
|
+
if (all['@remix-run/react'] || all['remix']) return 'Remix'
|
|
204
|
+
if (all['gatsby']) return 'Gatsby'
|
|
205
|
+
if (all['astro']) return 'Astro'
|
|
206
|
+
if (all['@angular/core']) return 'Angular'
|
|
207
|
+
if (all['vue']) return 'Vue'
|
|
208
|
+
if (all['svelte']) return 'Svelte'
|
|
209
|
+
if (all['react']) return 'React'
|
|
210
|
+
return null
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function detectCssStrategy(
|
|
214
|
+
deps: Record<string, string>,
|
|
215
|
+
devDeps: Record<string, string>,
|
|
216
|
+
hasCssModules: boolean,
|
|
217
|
+
cssFiles: string[],
|
|
218
|
+
): string[] {
|
|
219
|
+
const all = { ...deps, ...devDeps }
|
|
220
|
+
const strategies: string[] = []
|
|
221
|
+
|
|
222
|
+
if (all['tailwindcss']) strategies.push('Tailwind')
|
|
223
|
+
if (hasCssModules) strategies.push('CSS Modules')
|
|
224
|
+
if (all['styled-components']) strategies.push('styled-components')
|
|
225
|
+
if (all['@emotion/react'] || all['@emotion/styled'])
|
|
226
|
+
strategies.push('Emotion')
|
|
227
|
+
if (all['sass'] || all['node-sass']) strategies.push('Sass')
|
|
228
|
+
if (all['less']) strategies.push('Less')
|
|
229
|
+
if (all['@vanilla-extract/css']) strategies.push('Vanilla Extract')
|
|
230
|
+
|
|
231
|
+
if (strategies.length === 0 && cssFiles.length > 0) {
|
|
232
|
+
strategies.push('CSS')
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return strategies
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function detectRootConfigs(resolved: string, cssFiles: string[]) {
|
|
239
|
+
const configPatterns = [
|
|
240
|
+
'tailwind.config.js',
|
|
241
|
+
'tailwind.config.ts',
|
|
242
|
+
'tailwind.config.mjs',
|
|
243
|
+
'tailwind.config.cjs',
|
|
244
|
+
'postcss.config.js',
|
|
245
|
+
'postcss.config.ts',
|
|
246
|
+
'postcss.config.mjs',
|
|
247
|
+
'postcss.config.cjs',
|
|
248
|
+
]
|
|
249
|
+
for (const config of configPatterns) {
|
|
250
|
+
const configPath = path.join(resolved, config)
|
|
251
|
+
if (existsSync(configPath)) {
|
|
252
|
+
cssFiles.push(config)
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function detectAssetDirs(resolved: string, assetDirs: Set<string>) {
|
|
258
|
+
const publicDir = path.join(resolved, 'public')
|
|
259
|
+
if (existsSync(publicDir)) {
|
|
260
|
+
try {
|
|
261
|
+
const entries = readdirSync(publicDir, { withFileTypes: true })
|
|
262
|
+
for (const entry of entries) {
|
|
263
|
+
if (
|
|
264
|
+
entry.isDirectory() &&
|
|
265
|
+
ASSET_DIR_NAMES.has(entry.name.toLowerCase())
|
|
266
|
+
) {
|
|
267
|
+
assetDirs.add('public/' + entry.name)
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
if (entries.some((e) => e.isFile())) {
|
|
271
|
+
assetDirs.add('public')
|
|
272
|
+
}
|
|
273
|
+
} catch {
|
|
274
|
+
/* skip */
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Scan a project directory and return structured metadata.
|
|
281
|
+
* Requires a validated, resolved absolute path.
|
|
282
|
+
*/
|
|
283
|
+
export function scanProject(resolvedRoot: string): ProjectScanData {
|
|
284
|
+
const pkgPath = path.join(resolvedRoot, 'package.json')
|
|
285
|
+
let projectName = 'unknown'
|
|
286
|
+
let deps: Record<string, string> = {}
|
|
287
|
+
let devDeps: Record<string, string> = {}
|
|
288
|
+
|
|
289
|
+
if (existsSync(pkgPath)) {
|
|
290
|
+
try {
|
|
291
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'))
|
|
292
|
+
projectName = pkg.name || 'unknown'
|
|
293
|
+
deps = pkg.dependencies || {}
|
|
294
|
+
devDeps = pkg.devDependencies || {}
|
|
295
|
+
} catch {
|
|
296
|
+
// couldn't parse — continue with defaults
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const collectors: WalkCollectors = {
|
|
301
|
+
componentFileMap: {},
|
|
302
|
+
cssFiles: [],
|
|
303
|
+
routes: [],
|
|
304
|
+
assetDirs: new Set(),
|
|
305
|
+
hasCssModules: false,
|
|
306
|
+
}
|
|
307
|
+
const counter = { count: 0 }
|
|
308
|
+
const scannedRoots: string[] = []
|
|
309
|
+
const candidateDirs = ['src', 'app', 'components', 'lib', 'pages', 'styles']
|
|
310
|
+
const srcDirs: string[] = []
|
|
311
|
+
|
|
312
|
+
for (const dir of candidateDirs) {
|
|
313
|
+
const fullDir = path.join(resolvedRoot, dir)
|
|
314
|
+
if (!existsSync(fullDir)) continue
|
|
315
|
+
|
|
316
|
+
const alreadyCovered = scannedRoots.some((root) =>
|
|
317
|
+
fullDir.startsWith(root + path.sep),
|
|
318
|
+
)
|
|
319
|
+
if (alreadyCovered) continue
|
|
320
|
+
|
|
321
|
+
try {
|
|
322
|
+
const entries = readdirSync(fullDir, { withFileTypes: true })
|
|
323
|
+
if (entries.length > 0) {
|
|
324
|
+
walkDir(fullDir, resolvedRoot, collectors, counter)
|
|
325
|
+
scannedRoots.push(fullDir)
|
|
326
|
+
srcDirs.push(dir)
|
|
327
|
+
}
|
|
328
|
+
} catch {
|
|
329
|
+
/* skip inaccessible dirs */
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
detectRootConfigs(resolvedRoot, collectors.cssFiles)
|
|
334
|
+
detectAssetDirs(resolvedRoot, collectors.assetDirs)
|
|
335
|
+
|
|
336
|
+
const framework = detectFramework(deps, devDeps)
|
|
337
|
+
const cssStrategy = detectCssStrategy(
|
|
338
|
+
deps,
|
|
339
|
+
devDeps,
|
|
340
|
+
collectors.hasCssModules,
|
|
341
|
+
collectors.cssFiles,
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
return {
|
|
345
|
+
projectName,
|
|
346
|
+
componentCount: Object.keys(collectors.componentFileMap).length,
|
|
347
|
+
componentFileMap: collectors.componentFileMap,
|
|
348
|
+
framework,
|
|
349
|
+
cssStrategy,
|
|
350
|
+
cssFiles: collectors.cssFiles,
|
|
351
|
+
srcDirs,
|
|
352
|
+
assetDirs: Array.from(collectors.assetDirs),
|
|
353
|
+
routes: collectors.routes,
|
|
354
|
+
}
|
|
355
|
+
}
|