@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,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser-side SSE consumer for streaming Claude CLI activity.
|
|
3
|
+
* Uses fetch + ReadableStream (not EventSource, which only supports GET).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// ANSI escape codes
|
|
7
|
+
const ANSI = {
|
|
8
|
+
reset: '\x1b[0m',
|
|
9
|
+
bold: '\x1b[1m',
|
|
10
|
+
red: '\x1b[31m',
|
|
11
|
+
green: '\x1b[32m',
|
|
12
|
+
yellow: '\x1b[33m',
|
|
13
|
+
blue: '\x1b[34m',
|
|
14
|
+
magenta: '\x1b[35m',
|
|
15
|
+
cyan: '\x1b[36m',
|
|
16
|
+
gray: '\x1b[90m',
|
|
17
|
+
} as const
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Classify a stderr line and wrap it in ANSI colors for xterm.js display.
|
|
21
|
+
*/
|
|
22
|
+
export function formatStderrLine(line: string): string {
|
|
23
|
+
const trimmed = line.trim()
|
|
24
|
+
if (!trimmed) return ''
|
|
25
|
+
|
|
26
|
+
// File reads
|
|
27
|
+
if (
|
|
28
|
+
/^(Reading|Read)\s/i.test(trimmed) ||
|
|
29
|
+
/\.tsx?|\.jsx?|\.css|\.html/i.test(trimmed)
|
|
30
|
+
) {
|
|
31
|
+
return `${ANSI.magenta} ${trimmed}${ANSI.reset}`
|
|
32
|
+
}
|
|
33
|
+
// Tool usage
|
|
34
|
+
if (/^Tool:\s/i.test(trimmed) || /^Using\s/i.test(trimmed)) {
|
|
35
|
+
return `${ANSI.cyan} ${trimmed}${ANSI.reset}`
|
|
36
|
+
}
|
|
37
|
+
// Success
|
|
38
|
+
if (/success|complete|done|finished/i.test(trimmed)) {
|
|
39
|
+
return `${ANSI.green} ${trimmed}${ANSI.reset}`
|
|
40
|
+
}
|
|
41
|
+
// Errors
|
|
42
|
+
if (/error|fail|exception/i.test(trimmed)) {
|
|
43
|
+
return `${ANSI.red} ${trimmed}${ANSI.reset}`
|
|
44
|
+
}
|
|
45
|
+
// Warnings
|
|
46
|
+
if (/warn|warning|caution/i.test(trimmed)) {
|
|
47
|
+
return `${ANSI.yellow} ${trimmed}${ANSI.reset}`
|
|
48
|
+
}
|
|
49
|
+
// Unclassified
|
|
50
|
+
return `${ANSI.gray} ${trimmed}${ANSI.reset}`
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface StreamCallbacks<T> {
|
|
54
|
+
onStderr?: (line: string) => void
|
|
55
|
+
onResult?: (data: T) => void
|
|
56
|
+
onError?: (error: { code: string; message: string }) => void
|
|
57
|
+
onDone?: () => void
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Send a POST with `Accept: text/event-stream` and consume the SSE response.
|
|
62
|
+
* Returns an AbortController for cancellation.
|
|
63
|
+
*/
|
|
64
|
+
export function consumeClaudeStream<T>(
|
|
65
|
+
url: string,
|
|
66
|
+
body: Record<string, unknown>,
|
|
67
|
+
callbacks: StreamCallbacks<T>,
|
|
68
|
+
): AbortController {
|
|
69
|
+
const controller = new AbortController()
|
|
70
|
+
|
|
71
|
+
;(async () => {
|
|
72
|
+
try {
|
|
73
|
+
const res = await fetch(url, {
|
|
74
|
+
method: 'POST',
|
|
75
|
+
headers: {
|
|
76
|
+
'Content-Type': 'application/json',
|
|
77
|
+
Accept: 'text/event-stream',
|
|
78
|
+
},
|
|
79
|
+
body: JSON.stringify(body),
|
|
80
|
+
signal: controller.signal,
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
if (!res.ok || !res.body) {
|
|
84
|
+
const data = await res.json().catch(() => ({}))
|
|
85
|
+
callbacks.onError?.({
|
|
86
|
+
code: data.code || 'HTTP_ERROR',
|
|
87
|
+
message: data.error || `Request failed with status ${res.status}`,
|
|
88
|
+
})
|
|
89
|
+
callbacks.onDone?.()
|
|
90
|
+
return
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const reader = res.body.getReader()
|
|
94
|
+
const decoder = new TextDecoder()
|
|
95
|
+
let buffer = ''
|
|
96
|
+
|
|
97
|
+
while (true) {
|
|
98
|
+
const { done, value } = await reader.read()
|
|
99
|
+
if (done) break
|
|
100
|
+
|
|
101
|
+
buffer += decoder.decode(value, { stream: true })
|
|
102
|
+
|
|
103
|
+
// Parse SSE events from buffer
|
|
104
|
+
const parts = buffer.split('\n\n')
|
|
105
|
+
// Keep the last part — it may be incomplete
|
|
106
|
+
buffer = parts.pop() || ''
|
|
107
|
+
|
|
108
|
+
for (const part of parts) {
|
|
109
|
+
const lines = part.split('\n')
|
|
110
|
+
let eventType = ''
|
|
111
|
+
let dataStr = ''
|
|
112
|
+
|
|
113
|
+
for (const line of lines) {
|
|
114
|
+
if (line.startsWith('event: ')) {
|
|
115
|
+
eventType = line.slice(7)
|
|
116
|
+
} else if (line.startsWith('data: ')) {
|
|
117
|
+
dataStr = line.slice(6)
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (!eventType || !dataStr) continue
|
|
122
|
+
|
|
123
|
+
try {
|
|
124
|
+
const payload = JSON.parse(dataStr)
|
|
125
|
+
|
|
126
|
+
switch (eventType) {
|
|
127
|
+
case 'stderr':
|
|
128
|
+
callbacks.onStderr?.(payload.line)
|
|
129
|
+
break
|
|
130
|
+
case 'result':
|
|
131
|
+
callbacks.onResult?.(payload as T)
|
|
132
|
+
break
|
|
133
|
+
case 'error':
|
|
134
|
+
callbacks.onError?.(payload)
|
|
135
|
+
break
|
|
136
|
+
case 'done':
|
|
137
|
+
// Stream ended
|
|
138
|
+
break
|
|
139
|
+
}
|
|
140
|
+
} catch {
|
|
141
|
+
// Skip malformed JSON
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
} catch (err) {
|
|
146
|
+
if ((err as Error).name !== 'AbortError') {
|
|
147
|
+
callbacks.onError?.({
|
|
148
|
+
code: 'NETWORK_ERROR',
|
|
149
|
+
message: err instanceof Error ? err.message : 'Network error',
|
|
150
|
+
})
|
|
151
|
+
}
|
|
152
|
+
} finally {
|
|
153
|
+
callbacks.onDone?.()
|
|
154
|
+
}
|
|
155
|
+
})()
|
|
156
|
+
|
|
157
|
+
return controller
|
|
158
|
+
}
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side project scanner using the File System Access API.
|
|
3
|
+
* Mirrors the server-side /api/project-scan logic but runs entirely in the browser.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { RouteEntry } from '@/types/claude'
|
|
7
|
+
|
|
8
|
+
export interface ClientScanResult {
|
|
9
|
+
projectName: string
|
|
10
|
+
componentCount: number
|
|
11
|
+
componentFileMap: Record<string, string>
|
|
12
|
+
framework: string | null
|
|
13
|
+
cssStrategy: string[]
|
|
14
|
+
cssFiles: string[]
|
|
15
|
+
srcDirs: string[]
|
|
16
|
+
assetDirs: string[]
|
|
17
|
+
routes: RouteEntry[]
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const SKIP_DIRS = new Set([
|
|
21
|
+
'node_modules',
|
|
22
|
+
'.next',
|
|
23
|
+
'dist',
|
|
24
|
+
'build',
|
|
25
|
+
'.git',
|
|
26
|
+
'__tests__',
|
|
27
|
+
'__mocks__',
|
|
28
|
+
'.turbo',
|
|
29
|
+
'.vercel',
|
|
30
|
+
'coverage',
|
|
31
|
+
])
|
|
32
|
+
|
|
33
|
+
const COMPONENT_EXTENSIONS = new Set(['.tsx', '.jsx'])
|
|
34
|
+
const MAYBE_COMPONENT_EXTENSIONS = new Set(['.ts', '.js'])
|
|
35
|
+
const CSS_EXTENSIONS = new Set(['.css', '.scss', '.sass', '.less'])
|
|
36
|
+
const MAX_FILES = 5000
|
|
37
|
+
|
|
38
|
+
const CONVENTION_FILES = new Set([
|
|
39
|
+
'page',
|
|
40
|
+
'layout',
|
|
41
|
+
'loading',
|
|
42
|
+
'error',
|
|
43
|
+
'not-found',
|
|
44
|
+
'template',
|
|
45
|
+
'default',
|
|
46
|
+
'route',
|
|
47
|
+
'proxy',
|
|
48
|
+
'middleware',
|
|
49
|
+
'global-error',
|
|
50
|
+
'instrumentation',
|
|
51
|
+
])
|
|
52
|
+
|
|
53
|
+
const ROUTE_CONVENTION_MAP: Record<string, RouteEntry['type']> = {
|
|
54
|
+
page: 'page',
|
|
55
|
+
layout: 'layout',
|
|
56
|
+
loading: 'loading',
|
|
57
|
+
error: 'error',
|
|
58
|
+
'not-found': 'not-found',
|
|
59
|
+
template: 'template',
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const PREFERRED_SEGMENTS = new Set(['components', 'ui', 'common', 'shared'])
|
|
63
|
+
|
|
64
|
+
const ASSET_DIR_NAMES = new Set([
|
|
65
|
+
'images',
|
|
66
|
+
'img',
|
|
67
|
+
'fonts',
|
|
68
|
+
'icons',
|
|
69
|
+
'media',
|
|
70
|
+
'assets',
|
|
71
|
+
'static',
|
|
72
|
+
'videos',
|
|
73
|
+
'svgs',
|
|
74
|
+
'illustrations',
|
|
75
|
+
])
|
|
76
|
+
|
|
77
|
+
function isPascalCase(name: string): boolean {
|
|
78
|
+
return /^[A-Z][a-zA-Z0-9]*$/.test(name)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function getExtension(name: string): string {
|
|
82
|
+
const i = name.lastIndexOf('.')
|
|
83
|
+
return i >= 0 ? name.slice(i) : ''
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function getBaseName(name: string): string {
|
|
87
|
+
const ext = getExtension(name)
|
|
88
|
+
return ext ? name.slice(0, -ext.length) : name
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function hasPreferredSegment(relativePath: string): boolean {
|
|
92
|
+
const segments = relativePath.split('/')
|
|
93
|
+
return segments.some((s) => PREFERRED_SEGMENTS.has(s))
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function filePathToUrlPattern(relativePath: string): string {
|
|
97
|
+
const parts = relativePath.split('/')
|
|
98
|
+
const routeParts = parts.slice(1, -1)
|
|
99
|
+
const filtered = routeParts.filter((p) => !p.startsWith('('))
|
|
100
|
+
return '/' + filtered.join('/')
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
interface Collectors {
|
|
104
|
+
componentFileMap: Record<string, string>
|
|
105
|
+
cssFiles: string[]
|
|
106
|
+
routes: RouteEntry[]
|
|
107
|
+
assetDirs: Set<string>
|
|
108
|
+
hasCssModules: boolean
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function walkDir(
|
|
112
|
+
dirHandle: FileSystemDirectoryHandle,
|
|
113
|
+
relativePath: string,
|
|
114
|
+
collectors: Collectors,
|
|
115
|
+
counter: { count: number },
|
|
116
|
+
): Promise<void> {
|
|
117
|
+
if (counter.count >= MAX_FILES) return
|
|
118
|
+
|
|
119
|
+
for await (const [name, entryHandle] of dirHandle.entries()) {
|
|
120
|
+
if (counter.count >= MAX_FILES) return
|
|
121
|
+
if (SKIP_DIRS.has(name)) continue
|
|
122
|
+
|
|
123
|
+
if (entryHandle.kind === 'directory') {
|
|
124
|
+
if (ASSET_DIR_NAMES.has(name.toLowerCase())) {
|
|
125
|
+
const relDir = relativePath ? `${relativePath}/${name}` : name
|
|
126
|
+
collectors.assetDirs.add(relDir)
|
|
127
|
+
}
|
|
128
|
+
const childPath = relativePath ? `${relativePath}/${name}` : name
|
|
129
|
+
await walkDir(
|
|
130
|
+
entryHandle as FileSystemDirectoryHandle,
|
|
131
|
+
childPath,
|
|
132
|
+
collectors,
|
|
133
|
+
counter,
|
|
134
|
+
)
|
|
135
|
+
continue
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
counter.count++
|
|
139
|
+
const ext = getExtension(name)
|
|
140
|
+
const baseName = getBaseName(name)
|
|
141
|
+
const filePath = relativePath ? `${relativePath}/${name}` : name
|
|
142
|
+
|
|
143
|
+
if (baseName.startsWith('.')) continue
|
|
144
|
+
|
|
145
|
+
// CSS files
|
|
146
|
+
if (CSS_EXTENSIONS.has(ext)) {
|
|
147
|
+
collectors.cssFiles.push(filePath)
|
|
148
|
+
if (name.includes('.module.')) {
|
|
149
|
+
collectors.hasCssModules = true
|
|
150
|
+
}
|
|
151
|
+
continue
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Test files
|
|
155
|
+
if (baseName.endsWith('.test') || baseName.endsWith('.spec')) continue
|
|
156
|
+
|
|
157
|
+
// Route/convention files
|
|
158
|
+
const routeType = ROUTE_CONVENTION_MAP[baseName]
|
|
159
|
+
if (
|
|
160
|
+
routeType &&
|
|
161
|
+
(COMPONENT_EXTENSIONS.has(ext) || MAYBE_COMPONENT_EXTENSIONS.has(ext))
|
|
162
|
+
) {
|
|
163
|
+
if (filePath.startsWith('app/') || filePath.startsWith('src/app/')) {
|
|
164
|
+
const normalizedPath = filePath.startsWith('src/')
|
|
165
|
+
? filePath.slice(4)
|
|
166
|
+
: filePath
|
|
167
|
+
collectors.routes.push({
|
|
168
|
+
urlPattern: filePathToUrlPattern(normalizedPath),
|
|
169
|
+
filePath,
|
|
170
|
+
type: routeType,
|
|
171
|
+
})
|
|
172
|
+
}
|
|
173
|
+
continue
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (baseName === 'index') continue
|
|
177
|
+
if (CONVENTION_FILES.has(baseName)) continue
|
|
178
|
+
|
|
179
|
+
// Component files
|
|
180
|
+
const isComponentExt = COMPONENT_EXTENSIONS.has(ext)
|
|
181
|
+
const isMaybeComponentExt = MAYBE_COMPONENT_EXTENSIONS.has(ext)
|
|
182
|
+
if (!isComponentExt && !isMaybeComponentExt) continue
|
|
183
|
+
if (isMaybeComponentExt && !isPascalCase(baseName)) continue
|
|
184
|
+
|
|
185
|
+
if (collectors.componentFileMap[baseName]) {
|
|
186
|
+
const existingPreferred = hasPreferredSegment(
|
|
187
|
+
collectors.componentFileMap[baseName],
|
|
188
|
+
)
|
|
189
|
+
const newPreferred = hasPreferredSegment(filePath)
|
|
190
|
+
if (newPreferred && !existingPreferred) {
|
|
191
|
+
collectors.componentFileMap[baseName] = filePath
|
|
192
|
+
}
|
|
193
|
+
} else {
|
|
194
|
+
collectors.componentFileMap[baseName] = filePath
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function detectFramework(
|
|
200
|
+
deps: Record<string, string>,
|
|
201
|
+
devDeps: Record<string, string>,
|
|
202
|
+
): string | null {
|
|
203
|
+
const all = { ...deps, ...devDeps }
|
|
204
|
+
if (all['next']) return 'Next.js'
|
|
205
|
+
if (all['@remix-run/react'] || all['remix']) return 'Remix'
|
|
206
|
+
if (all['gatsby']) return 'Gatsby'
|
|
207
|
+
if (all['astro']) return 'Astro'
|
|
208
|
+
if (all['@angular/core']) return 'Angular'
|
|
209
|
+
if (all['vue']) return 'Vue'
|
|
210
|
+
if (all['svelte']) return 'Svelte'
|
|
211
|
+
if (all['react']) return 'React'
|
|
212
|
+
return null
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function detectCssStrategy(
|
|
216
|
+
deps: Record<string, string>,
|
|
217
|
+
devDeps: Record<string, string>,
|
|
218
|
+
hasCssModules: boolean,
|
|
219
|
+
cssFiles: string[],
|
|
220
|
+
): string[] {
|
|
221
|
+
const all = { ...deps, ...devDeps }
|
|
222
|
+
const strategies: string[] = []
|
|
223
|
+
if (all['tailwindcss']) strategies.push('Tailwind')
|
|
224
|
+
if (hasCssModules) strategies.push('CSS Modules')
|
|
225
|
+
if (all['styled-components']) strategies.push('styled-components')
|
|
226
|
+
if (all['@emotion/react'] || all['@emotion/styled'])
|
|
227
|
+
strategies.push('Emotion')
|
|
228
|
+
if (all['sass'] || all['node-sass']) strategies.push('Sass')
|
|
229
|
+
if (all['less']) strategies.push('Less')
|
|
230
|
+
if (all['@vanilla-extract/css']) strategies.push('Vanilla Extract')
|
|
231
|
+
if (strategies.length === 0 && cssFiles.length > 0) strategies.push('CSS')
|
|
232
|
+
return strategies
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Scan a project directory using the File System Access API.
|
|
237
|
+
* Returns the same shape as the server-side /api/project-scan endpoint.
|
|
238
|
+
*/
|
|
239
|
+
export async function scanProjectClient(
|
|
240
|
+
rootHandle: FileSystemDirectoryHandle,
|
|
241
|
+
): Promise<ClientScanResult> {
|
|
242
|
+
// Read package.json
|
|
243
|
+
let projectName = rootHandle.name || 'unknown'
|
|
244
|
+
let deps: Record<string, string> = {}
|
|
245
|
+
let devDeps: Record<string, string> = {}
|
|
246
|
+
|
|
247
|
+
try {
|
|
248
|
+
const pkgHandle = await rootHandle.getFileHandle('package.json')
|
|
249
|
+
const pkgFile = await pkgHandle.getFile()
|
|
250
|
+
const pkgText = await pkgFile.text()
|
|
251
|
+
const pkg = JSON.parse(pkgText)
|
|
252
|
+
projectName = pkg.name || projectName
|
|
253
|
+
deps = pkg.dependencies || {}
|
|
254
|
+
devDeps = pkg.devDependencies || {}
|
|
255
|
+
} catch {
|
|
256
|
+
// No package.json or couldn't parse — continue with defaults
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const collectors: Collectors = {
|
|
260
|
+
componentFileMap: {},
|
|
261
|
+
cssFiles: [],
|
|
262
|
+
routes: [],
|
|
263
|
+
assetDirs: new Set(),
|
|
264
|
+
hasCssModules: false,
|
|
265
|
+
}
|
|
266
|
+
const counter = { count: 0 }
|
|
267
|
+
const srcDirs: string[] = []
|
|
268
|
+
const candidateDirs = ['src', 'app', 'components', 'lib', 'pages', 'styles']
|
|
269
|
+
const scannedRoots: string[] = []
|
|
270
|
+
|
|
271
|
+
for (const dir of candidateDirs) {
|
|
272
|
+
const alreadyCovered = scannedRoots.some((root) =>
|
|
273
|
+
dir.startsWith(root + '/'),
|
|
274
|
+
)
|
|
275
|
+
if (alreadyCovered) continue
|
|
276
|
+
|
|
277
|
+
try {
|
|
278
|
+
const dirHandle = await rootHandle.getDirectoryHandle(dir)
|
|
279
|
+
await walkDir(dirHandle, dir, collectors, counter)
|
|
280
|
+
scannedRoots.push(dir)
|
|
281
|
+
srcDirs.push(dir)
|
|
282
|
+
} catch {
|
|
283
|
+
// Directory doesn't exist — skip
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Detect root-level config files
|
|
288
|
+
const configPatterns = [
|
|
289
|
+
'tailwind.config.js',
|
|
290
|
+
'tailwind.config.ts',
|
|
291
|
+
'tailwind.config.mjs',
|
|
292
|
+
'tailwind.config.cjs',
|
|
293
|
+
'postcss.config.js',
|
|
294
|
+
'postcss.config.ts',
|
|
295
|
+
'postcss.config.mjs',
|
|
296
|
+
'postcss.config.cjs',
|
|
297
|
+
]
|
|
298
|
+
for (const config of configPatterns) {
|
|
299
|
+
try {
|
|
300
|
+
await rootHandle.getFileHandle(config)
|
|
301
|
+
collectors.cssFiles.push(config)
|
|
302
|
+
} catch {
|
|
303
|
+
// Not found — skip
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Detect asset dirs under public/
|
|
308
|
+
try {
|
|
309
|
+
const publicHandle = await rootHandle.getDirectoryHandle('public')
|
|
310
|
+
for await (const [name, entry] of publicHandle.entries()) {
|
|
311
|
+
if (
|
|
312
|
+
entry.kind === 'directory' &&
|
|
313
|
+
ASSET_DIR_NAMES.has(name.toLowerCase())
|
|
314
|
+
) {
|
|
315
|
+
collectors.assetDirs.add('public/' + name)
|
|
316
|
+
}
|
|
317
|
+
if (entry.kind === 'file') {
|
|
318
|
+
collectors.assetDirs.add('public')
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
} catch {
|
|
322
|
+
// No public dir
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const framework = detectFramework(deps, devDeps)
|
|
326
|
+
const cssStrategy = detectCssStrategy(
|
|
327
|
+
deps,
|
|
328
|
+
devDeps,
|
|
329
|
+
collectors.hasCssModules,
|
|
330
|
+
collectors.cssFiles,
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
return {
|
|
334
|
+
projectName,
|
|
335
|
+
componentCount: Object.keys(collectors.componentFileMap).length,
|
|
336
|
+
componentFileMap: collectors.componentFileMap,
|
|
337
|
+
framework,
|
|
338
|
+
cssStrategy,
|
|
339
|
+
cssFiles: collectors.cssFiles,
|
|
340
|
+
srcDirs,
|
|
341
|
+
assetDirs: Array.from(collectors.assetDirs),
|
|
342
|
+
routes: collectors.routes,
|
|
343
|
+
}
|
|
344
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Match a selected DOM element to a component file from the scanned file map.
|
|
3
|
+
*
|
|
4
|
+
* Pure function — only returns paths that exist in the provided file map.
|
|
5
|
+
* Never fabricates paths.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
interface ElementSignals {
|
|
9
|
+
attributes: Record<string, string>
|
|
10
|
+
id: string | null
|
|
11
|
+
className: string | null
|
|
12
|
+
selectorPath: string
|
|
13
|
+
tagName: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function kebabToPascal(str: string): string {
|
|
17
|
+
return str
|
|
18
|
+
.split('-')
|
|
19
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
20
|
+
.join('')
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function lookup(name: string, map: Record<string, string>): string | null {
|
|
24
|
+
if (map[name]) return map[name]
|
|
25
|
+
// Try without common suffixes
|
|
26
|
+
for (const suffix of ['Component', 'View', 'Page', 'Section', 'Widget']) {
|
|
27
|
+
if (map[name + suffix]) return map[name + suffix]
|
|
28
|
+
}
|
|
29
|
+
return null
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Extract potential component names from a CSS class string.
|
|
34
|
+
* Looks for PascalCase class names or kebab-case names that convert to PascalCase.
|
|
35
|
+
*/
|
|
36
|
+
function extractClassCandidates(className: string): string[] {
|
|
37
|
+
const candidates: string[] = []
|
|
38
|
+
const classes = className.split(/\s+/).filter(Boolean)
|
|
39
|
+
|
|
40
|
+
for (const cls of classes) {
|
|
41
|
+
// Skip Tailwind utility classes (contain brackets, colons, slashes, or start with lowercase single segment)
|
|
42
|
+
if (cls.includes('[') || cls.includes(':') || cls.includes('/')) continue
|
|
43
|
+
if (cls.startsWith('-')) continue
|
|
44
|
+
|
|
45
|
+
// Already PascalCase
|
|
46
|
+
if (/^[A-Z][a-zA-Z0-9]+$/.test(cls)) {
|
|
47
|
+
candidates.push(cls)
|
|
48
|
+
continue
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Kebab-case that looks like a component name (at least 2 segments, no numbers-only segments)
|
|
52
|
+
if (cls.includes('-') && !cls.startsWith('_')) {
|
|
53
|
+
const parts = cls.split('-')
|
|
54
|
+
if (
|
|
55
|
+
parts.length >= 2 &&
|
|
56
|
+
parts.every((p) => p.length > 0 && !/^\d+$/.test(p))
|
|
57
|
+
) {
|
|
58
|
+
const pascal = kebabToPascal(cls)
|
|
59
|
+
if (/^[A-Z][a-zA-Z0-9]+$/.test(pascal)) {
|
|
60
|
+
candidates.push(pascal)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return candidates
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Extract ancestor tag/class hints from the selectorPath.
|
|
71
|
+
* selectorPath format: "body > div.container > main > section.hero > h1"
|
|
72
|
+
*/
|
|
73
|
+
function extractAncestorCandidates(selectorPath: string): string[] {
|
|
74
|
+
const candidates: string[] = []
|
|
75
|
+
const segments = selectorPath.split('>').map((s) => s.trim())
|
|
76
|
+
|
|
77
|
+
// Walk from innermost to outermost (skip the element itself)
|
|
78
|
+
for (let i = segments.length - 2; i >= 0; i--) {
|
|
79
|
+
const seg = segments[i]
|
|
80
|
+
|
|
81
|
+
// Extract class names from segment like "div.ClassName" or "section.hero-section"
|
|
82
|
+
const classMatch = seg.match(/\.([a-zA-Z][\w-]*)/g)
|
|
83
|
+
if (classMatch) {
|
|
84
|
+
for (const cls of classMatch) {
|
|
85
|
+
const name = cls.slice(1) // remove leading dot
|
|
86
|
+
if (/^[A-Z][a-zA-Z0-9]+$/.test(name)) {
|
|
87
|
+
candidates.push(name)
|
|
88
|
+
} else if (name.includes('-')) {
|
|
89
|
+
const pascal = kebabToPascal(name)
|
|
90
|
+
if (/^[A-Z][a-zA-Z0-9]+$/.test(pascal)) {
|
|
91
|
+
candidates.push(pascal)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Extract from data attributes in the segment (rare in selectorPath, but possible)
|
|
98
|
+
const dataMatch = seg.match(/\[data-component="([^"]+)"\]/)
|
|
99
|
+
if (dataMatch) {
|
|
100
|
+
candidates.push(dataMatch[1])
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return candidates
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function matchElementToComponent(
|
|
108
|
+
signals: ElementSignals,
|
|
109
|
+
fileMap: Record<string, string>,
|
|
110
|
+
): string | null {
|
|
111
|
+
// Strategy 1: data-component attribute → exact lookup
|
|
112
|
+
const dataComponent = signals.attributes['data-component']
|
|
113
|
+
if (dataComponent) {
|
|
114
|
+
const result = lookup(dataComponent, fileMap)
|
|
115
|
+
if (result) return result
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Strategy 2: data-testid → convert kebab-to-PascalCase → lookup
|
|
119
|
+
const testId = signals.attributes['data-testid']
|
|
120
|
+
if (testId) {
|
|
121
|
+
const pascal = kebabToPascal(testId)
|
|
122
|
+
const result = lookup(pascal, fileMap)
|
|
123
|
+
if (result) return result
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Strategy 3: Element id → convert to PascalCase → lookup
|
|
127
|
+
if (signals.id) {
|
|
128
|
+
const pascal = kebabToPascal(signals.id)
|
|
129
|
+
const result = lookup(pascal, fileMap)
|
|
130
|
+
if (result) return result
|
|
131
|
+
|
|
132
|
+
// Try the id as-is if it's already PascalCase
|
|
133
|
+
if (/^[A-Z]/.test(signals.id)) {
|
|
134
|
+
const result2 = lookup(signals.id, fileMap)
|
|
135
|
+
if (result2) return result2
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Strategy 4: Walk up selectorPath ancestors
|
|
140
|
+
const ancestorCandidates = extractAncestorCandidates(signals.selectorPath)
|
|
141
|
+
for (const candidate of ancestorCandidates) {
|
|
142
|
+
const result = lookup(candidate, fileMap)
|
|
143
|
+
if (result) return result
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Strategy 5: Class names that look PascalCase or convert from kebab-case
|
|
147
|
+
if (signals.className) {
|
|
148
|
+
const classCandidates = extractClassCandidates(signals.className)
|
|
149
|
+
for (const candidate of classCandidates) {
|
|
150
|
+
const result = lookup(candidate, fileMap)
|
|
151
|
+
if (result) return result
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return null
|
|
156
|
+
}
|