@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,430 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Infer a likely source file path for a DOM element based on its
|
|
3
|
+
* tag name, class names, id, the current page path, and selector depth.
|
|
4
|
+
*
|
|
5
|
+
* When a FileMap is provided (from project scan), attempts filesystem-backed
|
|
6
|
+
* resolution first. Falls back to heuristics when no match is found.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type {
|
|
10
|
+
FileMap,
|
|
11
|
+
ComponentEntry,
|
|
12
|
+
RouteEntry,
|
|
13
|
+
SourceInfo,
|
|
14
|
+
} from '@/types/claude'
|
|
15
|
+
|
|
16
|
+
const LAYOUT_TAGS = new Set([
|
|
17
|
+
'html',
|
|
18
|
+
'body',
|
|
19
|
+
'header',
|
|
20
|
+
'footer',
|
|
21
|
+
'nav',
|
|
22
|
+
'aside',
|
|
23
|
+
'main',
|
|
24
|
+
])
|
|
25
|
+
|
|
26
|
+
const LAYOUT_HINTS = [
|
|
27
|
+
'layout',
|
|
28
|
+
'wrapper',
|
|
29
|
+
'container',
|
|
30
|
+
'sidebar',
|
|
31
|
+
'navbar',
|
|
32
|
+
'topbar',
|
|
33
|
+
'app-shell',
|
|
34
|
+
'shell',
|
|
35
|
+
'scaffold',
|
|
36
|
+
'frame',
|
|
37
|
+
'toolbar',
|
|
38
|
+
'drawer',
|
|
39
|
+
'app-bar',
|
|
40
|
+
'navigation',
|
|
41
|
+
'menu-bar',
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
const PAGE_HINTS = [
|
|
45
|
+
'page',
|
|
46
|
+
'view',
|
|
47
|
+
'screen',
|
|
48
|
+
'content',
|
|
49
|
+
'hero',
|
|
50
|
+
'banner',
|
|
51
|
+
'landing',
|
|
52
|
+
'home',
|
|
53
|
+
'dashboard',
|
|
54
|
+
'profile',
|
|
55
|
+
'settings',
|
|
56
|
+
'about',
|
|
57
|
+
'checkout',
|
|
58
|
+
'feed',
|
|
59
|
+
'detail',
|
|
60
|
+
'overview',
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
const COMPONENT_MAP: Record<string, string> = {
|
|
64
|
+
btn: 'Button',
|
|
65
|
+
button: 'Button',
|
|
66
|
+
card: 'Card',
|
|
67
|
+
modal: 'Modal',
|
|
68
|
+
dialog: 'Dialog',
|
|
69
|
+
dropdown: 'Dropdown',
|
|
70
|
+
popover: 'Popover',
|
|
71
|
+
tooltip: 'Tooltip',
|
|
72
|
+
badge: 'Badge',
|
|
73
|
+
chip: 'Chip',
|
|
74
|
+
avatar: 'Avatar',
|
|
75
|
+
icon: 'Icon',
|
|
76
|
+
alert: 'Alert',
|
|
77
|
+
toast: 'Toast',
|
|
78
|
+
accordion: 'Accordion',
|
|
79
|
+
tab: 'Tabs',
|
|
80
|
+
carousel: 'Carousel',
|
|
81
|
+
slider: 'Slider',
|
|
82
|
+
pagination: 'Pagination',
|
|
83
|
+
stepper: 'Stepper',
|
|
84
|
+
progress: 'Progress',
|
|
85
|
+
breadcrumb: 'Breadcrumb',
|
|
86
|
+
spinner: 'Spinner',
|
|
87
|
+
skeleton: 'Skeleton',
|
|
88
|
+
form: 'Form',
|
|
89
|
+
table: 'Table',
|
|
90
|
+
navbar: 'Navbar',
|
|
91
|
+
header: 'Header',
|
|
92
|
+
footer: 'Footer',
|
|
93
|
+
sidebar: 'Sidebar',
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function matchesAny(text: string, hints: string[]): boolean {
|
|
97
|
+
const lower = text.toLowerCase()
|
|
98
|
+
return hints.some((h) => lower.includes(h))
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function findComponentName(text: string): string | null {
|
|
102
|
+
const lower = text.toLowerCase()
|
|
103
|
+
for (const [hint, name] of Object.entries(COMPONENT_MAP)) {
|
|
104
|
+
if (lower.includes(hint)) return name
|
|
105
|
+
}
|
|
106
|
+
return null
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function toPascalCase(str: string): string {
|
|
110
|
+
return str
|
|
111
|
+
.split(/[-_\s]+/)
|
|
112
|
+
.filter(Boolean)
|
|
113
|
+
.map((s) => s.charAt(0).toUpperCase() + s.slice(1).toLowerCase())
|
|
114
|
+
.join('')
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Check if a URL path matches a route pattern with dynamic segments. */
|
|
118
|
+
function matchesUrlPattern(urlPath: string, pattern: string): boolean {
|
|
119
|
+
const urlSegs = urlPath
|
|
120
|
+
.replace(/^\/|\/$/g, '')
|
|
121
|
+
.split('/')
|
|
122
|
+
.filter(Boolean)
|
|
123
|
+
const patSegs = pattern
|
|
124
|
+
.replace(/^\/|\/$/g, '')
|
|
125
|
+
.split('/')
|
|
126
|
+
.filter(Boolean)
|
|
127
|
+
|
|
128
|
+
// Handle catch-all: [...slug] or [[...slug]]
|
|
129
|
+
if (patSegs.length > 0) {
|
|
130
|
+
const last = patSegs[patSegs.length - 1]
|
|
131
|
+
if (/^\[{1,2}\.\.\./.test(last)) {
|
|
132
|
+
// Catch-all matches if all preceding segments match
|
|
133
|
+
for (let i = 0; i < patSegs.length - 1; i++) {
|
|
134
|
+
if (i >= urlSegs.length) return false
|
|
135
|
+
if (!patSegs[i].startsWith('[') && patSegs[i] !== urlSegs[i])
|
|
136
|
+
return false
|
|
137
|
+
}
|
|
138
|
+
return urlSegs.length >= patSegs.length - 1
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (urlSegs.length !== patSegs.length) return false
|
|
143
|
+
for (let i = 0; i < patSegs.length; i++) {
|
|
144
|
+
// Dynamic segment matches anything
|
|
145
|
+
if (patSegs[i].startsWith('[') && patSegs[i].endsWith(']')) continue
|
|
146
|
+
if (patSegs[i] !== urlSegs[i]) return false
|
|
147
|
+
}
|
|
148
|
+
return true
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Find the best page route match for a given pagePath. */
|
|
152
|
+
function findPageRoute(
|
|
153
|
+
routes: RouteEntry[],
|
|
154
|
+
pagePath: string,
|
|
155
|
+
): RouteEntry | null {
|
|
156
|
+
const pageRoutes = routes.filter((r) => r.type === 'page')
|
|
157
|
+
// Exact match first
|
|
158
|
+
const exact = pageRoutes.find((r) => r.urlPattern === pagePath)
|
|
159
|
+
if (exact) return exact
|
|
160
|
+
// Dynamic segment match
|
|
161
|
+
const dynamic = pageRoutes.find((r) =>
|
|
162
|
+
matchesUrlPattern(pagePath, r.urlPattern),
|
|
163
|
+
)
|
|
164
|
+
if (dynamic) return dynamic
|
|
165
|
+
return null
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Attempt to resolve a source path from the FileMap. Returns null on no match. */
|
|
169
|
+
function resolveFromFileMap(opts: {
|
|
170
|
+
tagName: string
|
|
171
|
+
className: string | null
|
|
172
|
+
id: string | null
|
|
173
|
+
selectorPath: string | null
|
|
174
|
+
pagePath: string
|
|
175
|
+
fileMap: FileMap
|
|
176
|
+
}): string | null {
|
|
177
|
+
const { fileMap } = opts
|
|
178
|
+
const tag = opts.tagName.toLowerCase()
|
|
179
|
+
const cls = (opts.className || '').toLowerCase()
|
|
180
|
+
const id = (opts.id || '').toLowerCase()
|
|
181
|
+
const combined = `${cls} ${id} ${tag}`
|
|
182
|
+
const pagePath = opts.pagePath || '/'
|
|
183
|
+
|
|
184
|
+
// 1. Component match — check className/id/tagName against ComponentEntry.nameLower
|
|
185
|
+
for (const comp of fileMap.components) {
|
|
186
|
+
if (
|
|
187
|
+
comp.category === 'page' ||
|
|
188
|
+
comp.category === 'screen' ||
|
|
189
|
+
comp.category === 'layout'
|
|
190
|
+
)
|
|
191
|
+
continue
|
|
192
|
+
// Exact substring match: component name appears in the combined text
|
|
193
|
+
if (comp.nameLower.length >= 3 && combined.includes(comp.nameLower)) {
|
|
194
|
+
return comp.filePath
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Also try COMPONENT_MAP mapping: e.g. "btn" → look for "Button"
|
|
199
|
+
for (const [hint, name] of Object.entries(COMPONENT_MAP)) {
|
|
200
|
+
if (combined.includes(hint)) {
|
|
201
|
+
const nameLower = name.toLowerCase()
|
|
202
|
+
const match = fileMap.components.find((c) => c.nameLower === nameLower)
|
|
203
|
+
if (match) return match.filePath
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// 2. Layout match — if element looks like a layout, find the most specific layout route
|
|
208
|
+
const isLayoutHint =
|
|
209
|
+
LAYOUT_TAGS.has(tag) || matchesAny(combined, LAYOUT_HINTS)
|
|
210
|
+
if (isLayoutHint && fileMap.routes.length > 0) {
|
|
211
|
+
const layouts = fileMap.routes
|
|
212
|
+
.filter((r) => r.type === 'layout')
|
|
213
|
+
.filter(
|
|
214
|
+
(r) =>
|
|
215
|
+
matchesUrlPattern(pagePath, r.urlPattern) || r.urlPattern === '/',
|
|
216
|
+
)
|
|
217
|
+
if (layouts.length > 0) {
|
|
218
|
+
// Most specific layout = longest urlPattern
|
|
219
|
+
layouts.sort((a, b) => b.urlPattern.length - a.urlPattern.length)
|
|
220
|
+
return layouts[0].filePath
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// 3. Page/route match (strict) — for shallow elements or page-hint elements
|
|
225
|
+
if (fileMap.routes.length > 0) {
|
|
226
|
+
const pageRoute = findPageRoute(fileMap.routes, pagePath)
|
|
227
|
+
if (pageRoute) {
|
|
228
|
+
const depth = opts.selectorPath
|
|
229
|
+
? opts.selectorPath.split(' > ').length
|
|
230
|
+
: 0
|
|
231
|
+
if (depth <= 4 || matchesAny(combined, PAGE_HINTS))
|
|
232
|
+
return pageRoute.filePath
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// 4. Screen/view match — look for screen/page entries with name matching URL segment
|
|
237
|
+
const urlSegments = pagePath
|
|
238
|
+
.replace(/^\/|\/$/g, '')
|
|
239
|
+
.split('/')
|
|
240
|
+
.filter(Boolean)
|
|
241
|
+
if (urlSegments.length > 0) {
|
|
242
|
+
const screenEntries = fileMap.components.filter(
|
|
243
|
+
(c) =>
|
|
244
|
+
c.category === 'screen' ||
|
|
245
|
+
c.category === 'page' ||
|
|
246
|
+
c.category === 'view',
|
|
247
|
+
)
|
|
248
|
+
for (const seg of urlSegments) {
|
|
249
|
+
const segLower = seg.toLowerCase()
|
|
250
|
+
const match = screenEntries.find((c) => c.nameLower.includes(segLower))
|
|
251
|
+
if (match) return match.filePath
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// 5. Root page fallback — for "/" when no route matched, look for index/home screen entries
|
|
256
|
+
if (pagePath === '/' || pagePath === '') {
|
|
257
|
+
const screenEntries = fileMap.components.filter(
|
|
258
|
+
(c) =>
|
|
259
|
+
c.category === 'screen' ||
|
|
260
|
+
c.category === 'page' ||
|
|
261
|
+
c.category === 'view',
|
|
262
|
+
)
|
|
263
|
+
const homeNames = [
|
|
264
|
+
'index',
|
|
265
|
+
'home',
|
|
266
|
+
'main',
|
|
267
|
+
'app',
|
|
268
|
+
'homescreen',
|
|
269
|
+
'homeview',
|
|
270
|
+
'homepage',
|
|
271
|
+
'mainscreen',
|
|
272
|
+
]
|
|
273
|
+
const homeMatch = screenEntries.find((c) => homeNames.includes(c.nameLower))
|
|
274
|
+
if (homeMatch) return homeMatch.filePath
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// 6. Lenient page route fallback — return the page route file for the current path
|
|
278
|
+
// regardless of element depth (better than a hardcoded heuristic path)
|
|
279
|
+
if (fileMap.routes.length > 0) {
|
|
280
|
+
const pageRoute = findPageRoute(fileMap.routes, pagePath)
|
|
281
|
+
if (pageRoute) return pageRoute.filePath
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// 7. Last resort — if we have any routes at all, return the root page
|
|
285
|
+
// This is always better than the hardcoded heuristic fallback
|
|
286
|
+
if (fileMap.routes.length > 0) {
|
|
287
|
+
const rootPage = fileMap.routes.find(
|
|
288
|
+
(r) => r.type === 'page' && r.urlPattern === '/',
|
|
289
|
+
)
|
|
290
|
+
if (rootPage) return rootPage.filePath
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return null
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export function inferComponentWidgetName(opts: {
|
|
297
|
+
tagName: string
|
|
298
|
+
className: string | null
|
|
299
|
+
elementId: string | null
|
|
300
|
+
sourceInfo?: SourceInfo | null
|
|
301
|
+
}): string {
|
|
302
|
+
if (opts.sourceInfo?.componentName) {
|
|
303
|
+
return opts.sourceInfo.componentName
|
|
304
|
+
}
|
|
305
|
+
if (opts.sourceInfo?.componentChain?.length) {
|
|
306
|
+
return opts.sourceInfo.componentChain[
|
|
307
|
+
opts.sourceInfo.componentChain.length - 1
|
|
308
|
+
]
|
|
309
|
+
}
|
|
310
|
+
const combined = `${opts.className || ''} ${opts.elementId || ''} ${opts.tagName}`
|
|
311
|
+
const mapped = findComponentName(combined)
|
|
312
|
+
if (mapped) return mapped
|
|
313
|
+
return opts.tagName
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export function inferSourcePath(opts: {
|
|
317
|
+
tagName: string
|
|
318
|
+
className: string | null
|
|
319
|
+
id: string | null
|
|
320
|
+
selectorPath: string | null
|
|
321
|
+
pagePath: string
|
|
322
|
+
fileMap?: FileMap | null
|
|
323
|
+
sourceInfo?: SourceInfo | null
|
|
324
|
+
projectRoot?: string | null
|
|
325
|
+
}): string {
|
|
326
|
+
// Priority 0: React fiber _debugSource (exact file + line)
|
|
327
|
+
if (opts.sourceInfo?.fileName) {
|
|
328
|
+
const abs = opts.sourceInfo.fileName
|
|
329
|
+
if (opts.projectRoot) {
|
|
330
|
+
const root = opts.projectRoot.replace(/\/$/, '')
|
|
331
|
+
if (abs.startsWith(root + '/')) {
|
|
332
|
+
return abs.substring(root.length + 1)
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
// Return absolute path if can't make relative (still accurate)
|
|
336
|
+
return abs
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Try filesystem-backed resolution first
|
|
340
|
+
if (opts.fileMap) {
|
|
341
|
+
const resolved = resolveFromFileMap({
|
|
342
|
+
tagName: opts.tagName,
|
|
343
|
+
className: opts.className,
|
|
344
|
+
id: opts.id,
|
|
345
|
+
selectorPath: opts.selectorPath,
|
|
346
|
+
pagePath: opts.pagePath,
|
|
347
|
+
fileMap: opts.fileMap,
|
|
348
|
+
})
|
|
349
|
+
if (resolved) return resolved
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const tag = opts.tagName.toLowerCase()
|
|
353
|
+
const cls = opts.className || ''
|
|
354
|
+
const id = opts.id || ''
|
|
355
|
+
const combined = `${cls} ${id} ${tag}`
|
|
356
|
+
const pagePath = opts.pagePath || '/'
|
|
357
|
+
|
|
358
|
+
// 1. Layout — structural wrappers
|
|
359
|
+
if (LAYOUT_TAGS.has(tag) || matchesAny(combined, LAYOUT_HINTS)) {
|
|
360
|
+
// Try to guess specific component name from classes/id
|
|
361
|
+
const name = findComponentName(combined)
|
|
362
|
+
if (name && tag !== 'html' && tag !== 'body' && tag !== 'main') {
|
|
363
|
+
return `src/components/${name}.tsx`
|
|
364
|
+
}
|
|
365
|
+
// Root layout
|
|
366
|
+
if (pagePath === '/') return 'src/app/layout.tsx'
|
|
367
|
+
const segments = pagePath.replace(/^\/|\/$/g, '').split('/')
|
|
368
|
+
return `src/app/${segments.join('/')}/layout.tsx`
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// 2. Page-level content
|
|
372
|
+
if (matchesAny(combined, PAGE_HINTS)) {
|
|
373
|
+
if (pagePath === '/') return 'src/app/page.tsx'
|
|
374
|
+
const segments = pagePath.replace(/^\/|\/$/g, '').split('/')
|
|
375
|
+
return `src/app/${segments.join('/')}/page.tsx`
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// 3. Component — match known component names
|
|
379
|
+
const componentName = findComponentName(combined)
|
|
380
|
+
if (componentName) {
|
|
381
|
+
return `src/components/${componentName}.tsx`
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// 4. Interactive HTML elements → components
|
|
385
|
+
if (
|
|
386
|
+
tag === 'button' ||
|
|
387
|
+
tag === 'input' ||
|
|
388
|
+
tag === 'select' ||
|
|
389
|
+
tag === 'textarea' ||
|
|
390
|
+
tag === 'a' ||
|
|
391
|
+
tag === 'img' ||
|
|
392
|
+
tag === 'table' ||
|
|
393
|
+
tag === 'form' ||
|
|
394
|
+
tag === 'label'
|
|
395
|
+
) {
|
|
396
|
+
// Try to derive name from id or first meaningful class
|
|
397
|
+
if (id) return `src/components/${toPascalCase(id)}.tsx`
|
|
398
|
+
const firstClass = cls
|
|
399
|
+
.split(/\s+/)
|
|
400
|
+
.find(
|
|
401
|
+
(c) =>
|
|
402
|
+
c.length > 2 &&
|
|
403
|
+
!c.startsWith('text-') &&
|
|
404
|
+
!c.startsWith('bg-') &&
|
|
405
|
+
!c.startsWith('flex') &&
|
|
406
|
+
!c.startsWith('p-') &&
|
|
407
|
+
!c.startsWith('m-'),
|
|
408
|
+
)
|
|
409
|
+
if (firstClass) return `src/components/${toPascalCase(firstClass)}.tsx`
|
|
410
|
+
return 'src/components/'
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// 5. Depth heuristic — shallow = layout/page, deep = component
|
|
414
|
+
if (opts.selectorPath) {
|
|
415
|
+
const depth = opts.selectorPath.split(' > ').length
|
|
416
|
+
if (depth <= 2) {
|
|
417
|
+
if (pagePath === '/') return 'src/app/layout.tsx'
|
|
418
|
+
return `src/app/${pagePath.replace(/^\/|\/$/g, '')}/layout.tsx`
|
|
419
|
+
}
|
|
420
|
+
if (depth <= 4) {
|
|
421
|
+
if (pagePath === '/') return 'src/app/page.tsx'
|
|
422
|
+
return `src/app/${pagePath.replace(/^\/|\/$/g, '')}/page.tsx`
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Default — page file
|
|
427
|
+
if (pagePath === '/') return 'src/app/page.tsx'
|
|
428
|
+
const segments = pagePath.replace(/^\/|\/$/g, '').split('/')
|
|
429
|
+
return `src/app/${segments.join('/')}/page.tsx`
|
|
430
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { existsSync, readdirSync } from 'node:fs'
|
|
2
|
+
import { spawn, execFileSync } from 'node:child_process'
|
|
3
|
+
import { homedir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
|
|
6
|
+
let cached: string | null = null
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Resolve the absolute path to the `claude` CLI binary.
|
|
10
|
+
* Next.js server processes often lack HOME and have a minimal PATH,
|
|
11
|
+
* so we use os.homedir() and scan multiple known locations.
|
|
12
|
+
*/
|
|
13
|
+
export function getClaudeBin(): string {
|
|
14
|
+
if (cached) return cached
|
|
15
|
+
|
|
16
|
+
// os.homedir() works even when $HOME is unset (reads /etc/passwd)
|
|
17
|
+
const home = homedir()
|
|
18
|
+
const candidates = [
|
|
19
|
+
`${home}/.local/bin/claude`,
|
|
20
|
+
`${home}/.claude/local/claude`,
|
|
21
|
+
'/usr/local/bin/claude',
|
|
22
|
+
`${home}/.npm-global/bin/claude`,
|
|
23
|
+
`${home}/.bun/bin/claude`,
|
|
24
|
+
`${home}/.volta/bin/claude`,
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
// Also check nvm versioned dirs (the "current" symlink may not exist)
|
|
28
|
+
try {
|
|
29
|
+
const nvmDir = `${home}/.nvm/versions/node`
|
|
30
|
+
if (existsSync(nvmDir)) {
|
|
31
|
+
for (const ver of readdirSync(nvmDir)) {
|
|
32
|
+
candidates.push(join(nvmDir, ver, 'bin', 'claude'))
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
} catch {
|
|
36
|
+
/* ignore */
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
for (const p of candidates) {
|
|
40
|
+
if (existsSync(p)) {
|
|
41
|
+
cached = p
|
|
42
|
+
return cached
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Scan PATH directories as a last resort
|
|
47
|
+
const pathDirs = (process.env.PATH || '').split(':')
|
|
48
|
+
for (const dir of pathDirs) {
|
|
49
|
+
if (!dir) continue
|
|
50
|
+
const p = join(dir, 'claude')
|
|
51
|
+
if (existsSync(p)) {
|
|
52
|
+
cached = p
|
|
53
|
+
return cached
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Fall back to bare name — will only work if PATH happens to include it
|
|
58
|
+
return 'claude'
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface SpawnResult {
|
|
62
|
+
exitCode: number
|
|
63
|
+
stdout: string
|
|
64
|
+
stderr: string
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface StreamingSpawnResult {
|
|
68
|
+
exitCode: number
|
|
69
|
+
stdout: string
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface StreamingSpawnOptions {
|
|
73
|
+
cwd?: string
|
|
74
|
+
timeout?: number
|
|
75
|
+
onStderr?: (line: string) => void
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const AUTH_PATTERNS = [
|
|
79
|
+
/not authenticated/i,
|
|
80
|
+
/not logged in/i,
|
|
81
|
+
/authentication required/i,
|
|
82
|
+
/api key/i,
|
|
83
|
+
/unauthorized/i,
|
|
84
|
+
/login required/i,
|
|
85
|
+
/please log in/i,
|
|
86
|
+
/claude login/i,
|
|
87
|
+
/ANTHROPIC_API_KEY/,
|
|
88
|
+
]
|
|
89
|
+
|
|
90
|
+
/** Check if stderr indicates an authentication / login issue. */
|
|
91
|
+
export function isAuthError(stderr: string): boolean {
|
|
92
|
+
return AUTH_PATTERNS.some((re) => re.test(stderr))
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Spawn the claude CLI with given args using Node's child_process.
|
|
97
|
+
* Works in both Bun and Node runtimes (Next.js Turbopack uses Node).
|
|
98
|
+
*/
|
|
99
|
+
export function spawnClaude(
|
|
100
|
+
args: string[],
|
|
101
|
+
options: { cwd?: string; timeout?: number } = {},
|
|
102
|
+
): Promise<SpawnResult> {
|
|
103
|
+
const claudeBin = getClaudeBin()
|
|
104
|
+
const { cwd, timeout = 120_000 } = options
|
|
105
|
+
|
|
106
|
+
return new Promise((resolve, reject) => {
|
|
107
|
+
const proc = spawn(claudeBin, args, {
|
|
108
|
+
cwd,
|
|
109
|
+
env: { ...process.env, CLAUDECODE: undefined },
|
|
110
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
const stdoutChunks: Buffer[] = []
|
|
114
|
+
const stderrChunks: Buffer[] = []
|
|
115
|
+
|
|
116
|
+
proc.stdout.on('data', (chunk: Buffer) => stdoutChunks.push(chunk))
|
|
117
|
+
proc.stderr.on('data', (chunk: Buffer) => stderrChunks.push(chunk))
|
|
118
|
+
|
|
119
|
+
const timer = setTimeout(() => {
|
|
120
|
+
proc.kill('SIGKILL')
|
|
121
|
+
reject(new Error('TIMEOUT'))
|
|
122
|
+
}, timeout)
|
|
123
|
+
|
|
124
|
+
proc.on('close', (code) => {
|
|
125
|
+
clearTimeout(timer)
|
|
126
|
+
resolve({
|
|
127
|
+
exitCode: code ?? 1,
|
|
128
|
+
stdout: Buffer.concat(stdoutChunks).toString('utf-8'),
|
|
129
|
+
stderr: Buffer.concat(stderrChunks).toString('utf-8'),
|
|
130
|
+
})
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
proc.on('error', (err) => {
|
|
134
|
+
clearTimeout(timer)
|
|
135
|
+
reject(err)
|
|
136
|
+
})
|
|
137
|
+
})
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Spawn the claude CLI with streaming stderr.
|
|
142
|
+
* stderr is line-buffered and delivered via onStderr callback.
|
|
143
|
+
* stdout is fully buffered (contains diffs/JSON that need complete parsing).
|
|
144
|
+
*/
|
|
145
|
+
export function spawnClaudeStreaming(
|
|
146
|
+
args: string[],
|
|
147
|
+
options: StreamingSpawnOptions = {},
|
|
148
|
+
): Promise<StreamingSpawnResult> {
|
|
149
|
+
const claudeBin = getClaudeBin()
|
|
150
|
+
const { cwd, timeout = 120_000, onStderr } = options
|
|
151
|
+
|
|
152
|
+
return new Promise((resolve, reject) => {
|
|
153
|
+
const proc = spawn(claudeBin, args, {
|
|
154
|
+
cwd,
|
|
155
|
+
env: { ...process.env, CLAUDECODE: undefined },
|
|
156
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
const stdoutChunks: Buffer[] = []
|
|
160
|
+
let stderrPartial = ''
|
|
161
|
+
|
|
162
|
+
proc.stdout.on('data', (chunk: Buffer) => stdoutChunks.push(chunk))
|
|
163
|
+
|
|
164
|
+
proc.stderr.on('data', (chunk: Buffer) => {
|
|
165
|
+
if (!onStderr) return
|
|
166
|
+
stderrPartial += chunk.toString('utf-8')
|
|
167
|
+
const lines = stderrPartial.split('\n')
|
|
168
|
+
// Hold the last element — it may be a partial line
|
|
169
|
+
stderrPartial = lines.pop() || ''
|
|
170
|
+
for (const line of lines) {
|
|
171
|
+
if (line) onStderr(line)
|
|
172
|
+
}
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
const timer = setTimeout(() => {
|
|
176
|
+
proc.kill('SIGKILL')
|
|
177
|
+
reject(new Error('TIMEOUT'))
|
|
178
|
+
}, timeout)
|
|
179
|
+
|
|
180
|
+
proc.on('close', (code) => {
|
|
181
|
+
clearTimeout(timer)
|
|
182
|
+
// Flush remaining partial line
|
|
183
|
+
if (onStderr && stderrPartial) {
|
|
184
|
+
onStderr(stderrPartial)
|
|
185
|
+
}
|
|
186
|
+
resolve({
|
|
187
|
+
exitCode: code ?? 1,
|
|
188
|
+
stdout: Buffer.concat(stdoutChunks).toString('utf-8'),
|
|
189
|
+
})
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
proc.on('error', (err) => {
|
|
193
|
+
clearTimeout(timer)
|
|
194
|
+
reject(err)
|
|
195
|
+
})
|
|
196
|
+
})
|
|
197
|
+
}
|