@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,238 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
import { readdirSync, readFileSync, statSync } from 'node:fs'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { validateProjectRoot } from '@/lib/validatePath'
|
|
5
|
+
import {
|
|
6
|
+
extractDesignTokensFromSource,
|
|
7
|
+
TOKEN_FILE_NAMES,
|
|
8
|
+
} from '@/lib/cssVariableUtils'
|
|
9
|
+
|
|
10
|
+
const SKIP_DIRS = new Set([
|
|
11
|
+
'node_modules',
|
|
12
|
+
'.next',
|
|
13
|
+
'dist',
|
|
14
|
+
'build',
|
|
15
|
+
'.git',
|
|
16
|
+
'__tests__',
|
|
17
|
+
'__mocks__',
|
|
18
|
+
'.turbo',
|
|
19
|
+
'.vercel',
|
|
20
|
+
'coverage',
|
|
21
|
+
'.cache',
|
|
22
|
+
'.output',
|
|
23
|
+
])
|
|
24
|
+
|
|
25
|
+
const CSS_EXTENSIONS = new Set(['.css', '.scss', '.less'])
|
|
26
|
+
const MAX_FILES = 2000
|
|
27
|
+
const MAX_FILE_SIZE = 512 * 1024 // 512KB per file
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Regex to match CSS custom property definitions:
|
|
31
|
+
* --variable-name: value;
|
|
32
|
+
* Captures: group 1 = variable name, group 2 = value (before ;)
|
|
33
|
+
*/
|
|
34
|
+
const CSS_VAR_RE = /^\s*(--[\w-]+)\s*:\s*([^;]+);/gm
|
|
35
|
+
|
|
36
|
+
interface ScannedVariable {
|
|
37
|
+
value: string
|
|
38
|
+
resolvedValue: string
|
|
39
|
+
selector: string
|
|
40
|
+
source: string // relative file path
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function walkForCSS(
|
|
44
|
+
dir: string,
|
|
45
|
+
projectRoot: string,
|
|
46
|
+
results: Map<string, ScannedVariable>,
|
|
47
|
+
counter: { count: number },
|
|
48
|
+
): void {
|
|
49
|
+
if (counter.count >= MAX_FILES) return
|
|
50
|
+
|
|
51
|
+
let entries: import('node:fs').Dirent[]
|
|
52
|
+
try {
|
|
53
|
+
entries = readdirSync(dir, { withFileTypes: true })
|
|
54
|
+
} catch {
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
for (const entry of entries) {
|
|
59
|
+
if (counter.count >= MAX_FILES) return
|
|
60
|
+
if (entry.name.startsWith('.')) continue
|
|
61
|
+
if (SKIP_DIRS.has(entry.name)) continue
|
|
62
|
+
|
|
63
|
+
const fullPath = path.join(dir, entry.name)
|
|
64
|
+
|
|
65
|
+
if (entry.isDirectory()) {
|
|
66
|
+
walkForCSS(fullPath, projectRoot, results, counter)
|
|
67
|
+
continue
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (!entry.isFile()) continue
|
|
71
|
+
counter.count++
|
|
72
|
+
|
|
73
|
+
const ext = path.extname(entry.name)
|
|
74
|
+
if (!CSS_EXTENSIONS.has(ext)) continue
|
|
75
|
+
|
|
76
|
+
let content: string
|
|
77
|
+
try {
|
|
78
|
+
const stat = statSync(fullPath)
|
|
79
|
+
if (stat.size > MAX_FILE_SIZE) continue
|
|
80
|
+
content = readFileSync(fullPath, 'utf-8')
|
|
81
|
+
} catch {
|
|
82
|
+
continue
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const relativePath = path.relative(projectRoot, fullPath)
|
|
86
|
+
let match: RegExpExecArray | null
|
|
87
|
+
CSS_VAR_RE.lastIndex = 0
|
|
88
|
+
|
|
89
|
+
while ((match = CSS_VAR_RE.exec(content)) !== null) {
|
|
90
|
+
const name = match[1].trim()
|
|
91
|
+
const rawValue = match[2].trim()
|
|
92
|
+
|
|
93
|
+
// Skip framework internal variables
|
|
94
|
+
if (
|
|
95
|
+
name.startsWith('--tw-') ||
|
|
96
|
+
name.startsWith('--next-') ||
|
|
97
|
+
name.startsWith('--radix-') ||
|
|
98
|
+
name.startsWith('--chakra-') ||
|
|
99
|
+
name.startsWith('--mantine-') ||
|
|
100
|
+
name.startsWith('--mui-') ||
|
|
101
|
+
name.startsWith('--framer-') ||
|
|
102
|
+
name.startsWith('--sb-') ||
|
|
103
|
+
name.startsWith('--css-interop-')
|
|
104
|
+
) {
|
|
105
|
+
continue
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Only add first occurrence (keeps the most "root" definition)
|
|
109
|
+
if (!results.has(name)) {
|
|
110
|
+
results.set(name, {
|
|
111
|
+
value: rawValue,
|
|
112
|
+
resolvedValue: rawValue, // static scan — no computed resolution
|
|
113
|
+
selector: ':root',
|
|
114
|
+
source: relativePath,
|
|
115
|
+
})
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Walk the project tree looking for JS/TS/Dart design-token files
|
|
123
|
+
* (e.g. colors.ts, theme.dart, palette.js) and extract exported constants.
|
|
124
|
+
*/
|
|
125
|
+
function walkForTokenFiles(
|
|
126
|
+
dir: string,
|
|
127
|
+
projectRoot: string,
|
|
128
|
+
results: Record<
|
|
129
|
+
string,
|
|
130
|
+
{ value: string; resolvedValue: string; selector: string }
|
|
131
|
+
>,
|
|
132
|
+
counter: { count: number },
|
|
133
|
+
): void {
|
|
134
|
+
if (counter.count >= MAX_FILES) return
|
|
135
|
+
|
|
136
|
+
let entries: import('node:fs').Dirent[]
|
|
137
|
+
try {
|
|
138
|
+
entries = readdirSync(dir, { withFileTypes: true })
|
|
139
|
+
} catch {
|
|
140
|
+
return
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
for (const entry of entries) {
|
|
144
|
+
if (counter.count >= MAX_FILES) return
|
|
145
|
+
if (entry.name.startsWith('.')) continue
|
|
146
|
+
if (SKIP_DIRS.has(entry.name)) continue
|
|
147
|
+
|
|
148
|
+
const fullPath = path.join(dir, entry.name)
|
|
149
|
+
|
|
150
|
+
if (entry.isDirectory()) {
|
|
151
|
+
walkForTokenFiles(fullPath, projectRoot, results, counter)
|
|
152
|
+
continue
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (!entry.isFile()) continue
|
|
156
|
+
counter.count++
|
|
157
|
+
|
|
158
|
+
if (!TOKEN_FILE_NAMES.has(entry.name)) continue
|
|
159
|
+
|
|
160
|
+
let content: string
|
|
161
|
+
try {
|
|
162
|
+
const stat = statSync(fullPath)
|
|
163
|
+
if (stat.size > MAX_FILE_SIZE) continue
|
|
164
|
+
content = readFileSync(fullPath, 'utf-8')
|
|
165
|
+
} catch {
|
|
166
|
+
continue
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const relativePath = path.relative(projectRoot, fullPath)
|
|
170
|
+
const tokens = extractDesignTokensFromSource(content, relativePath)
|
|
171
|
+
|
|
172
|
+
for (const [name, def] of Object.entries(tokens)) {
|
|
173
|
+
if (!results[name]) {
|
|
174
|
+
results[name] = def
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export async function POST(request: Request): Promise<NextResponse> {
|
|
181
|
+
let body: { projectRoot?: string }
|
|
182
|
+
try {
|
|
183
|
+
body = await request.json()
|
|
184
|
+
} catch {
|
|
185
|
+
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const { projectRoot } = body
|
|
189
|
+
if (!projectRoot || typeof projectRoot !== 'string') {
|
|
190
|
+
return NextResponse.json(
|
|
191
|
+
{ error: 'projectRoot is required' },
|
|
192
|
+
{ status: 400 },
|
|
193
|
+
)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const rootError = validateProjectRoot(projectRoot)
|
|
197
|
+
if (rootError) {
|
|
198
|
+
return NextResponse.json({ error: rootError }, { status: 400 })
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const resolved = path.resolve(projectRoot)
|
|
202
|
+
|
|
203
|
+
try {
|
|
204
|
+
const cssResults = new Map<string, ScannedVariable>()
|
|
205
|
+
const counter = { count: 0 }
|
|
206
|
+
|
|
207
|
+
walkForCSS(resolved, resolved, cssResults, counter)
|
|
208
|
+
|
|
209
|
+
// Convert CSS results to the format expected by the store
|
|
210
|
+
const definitions: Record<
|
|
211
|
+
string,
|
|
212
|
+
{ value: string; resolvedValue: string; selector: string }
|
|
213
|
+
> = {}
|
|
214
|
+
for (const [name, def] of cssResults) {
|
|
215
|
+
definitions[name] = {
|
|
216
|
+
value: def.value,
|
|
217
|
+
resolvedValue: def.resolvedValue,
|
|
218
|
+
selector: def.selector,
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Also scan for JS/TS/Dart token files (React Native, Flutter, etc.)
|
|
223
|
+
const tokenCounter = { count: 0 }
|
|
224
|
+
walkForTokenFiles(resolved, resolved, definitions, tokenCounter)
|
|
225
|
+
|
|
226
|
+
return NextResponse.json({
|
|
227
|
+
definitions,
|
|
228
|
+
count: Object.keys(definitions).length,
|
|
229
|
+
filesScanned: counter.count + tokenCounter.count,
|
|
230
|
+
})
|
|
231
|
+
} catch (error) {
|
|
232
|
+
const message = error instanceof Error ? error.message : 'Unknown error'
|
|
233
|
+
return NextResponse.json(
|
|
234
|
+
{ error: 'Scan failed', details: message },
|
|
235
|
+
{ status: 500 },
|
|
236
|
+
)
|
|
237
|
+
}
|
|
238
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
import { existsSync } from 'node:fs'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { validateProjectRoot } from '@/lib/validatePath'
|
|
5
|
+
import { scanProject } from '@/lib/projectScanner'
|
|
6
|
+
|
|
7
|
+
export async function POST(request: Request): Promise<NextResponse> {
|
|
8
|
+
let body: { projectRoot?: string }
|
|
9
|
+
try {
|
|
10
|
+
body = await request.json()
|
|
11
|
+
} catch {
|
|
12
|
+
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const { projectRoot } = body
|
|
16
|
+
if (!projectRoot || typeof projectRoot !== 'string') {
|
|
17
|
+
return NextResponse.json(
|
|
18
|
+
{ error: 'projectRoot is required' },
|
|
19
|
+
{ status: 400 },
|
|
20
|
+
)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const rootError = validateProjectRoot(projectRoot)
|
|
24
|
+
if (rootError) {
|
|
25
|
+
return NextResponse.json({ error: rootError }, { status: 400 })
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const resolved = path.resolve(projectRoot)
|
|
29
|
+
|
|
30
|
+
const pkgPath = path.join(resolved, 'package.json')
|
|
31
|
+
if (!existsSync(pkgPath)) {
|
|
32
|
+
return NextResponse.json(
|
|
33
|
+
{ error: 'Not a valid project directory — no package.json found' },
|
|
34
|
+
{ status: 400 },
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const result = scanProject(resolved)
|
|
39
|
+
return NextResponse.json(result)
|
|
40
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { validateProjectRoot } from '@/lib/validatePath'
|
|
5
|
+
import type { CSSVariableDefinition } from '@/types/cssVariables'
|
|
6
|
+
|
|
7
|
+
const MAX_FILE_SIZE = 256 * 1024 // 256KB
|
|
8
|
+
|
|
9
|
+
/** Config file names to search for, in priority order */
|
|
10
|
+
const CONFIG_FILES = [
|
|
11
|
+
'tailwind.config.ts',
|
|
12
|
+
'tailwind.config.js',
|
|
13
|
+
'tailwind.config.mjs',
|
|
14
|
+
'tailwind.config.cjs',
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Extract color definitions from a Tailwind v3 config file.
|
|
19
|
+
*
|
|
20
|
+
* Parses patterns like:
|
|
21
|
+
* theme: { extend: { colors: { primary: '#4a9eff', secondary: { 50: '#fff', 500: '#333' } } } }
|
|
22
|
+
* theme: { colors: { ... } }
|
|
23
|
+
*
|
|
24
|
+
* Uses regex-based extraction (no JS eval for security).
|
|
25
|
+
*/
|
|
26
|
+
function extractTailwindColors(
|
|
27
|
+
source: string,
|
|
28
|
+
filePath: string,
|
|
29
|
+
): Record<string, CSSVariableDefinition> {
|
|
30
|
+
const results: Record<string, CSSVariableDefinition> = {}
|
|
31
|
+
|
|
32
|
+
// Strip comments
|
|
33
|
+
const cleaned = source
|
|
34
|
+
.replace(/\/\/.*$/gm, '')
|
|
35
|
+
.replace(/\/\*[\s\S]*?\*\//g, '')
|
|
36
|
+
|
|
37
|
+
// Find colors blocks in theme.extend.colors or theme.colors
|
|
38
|
+
// Match: colors: { ... } or colors: { ... },
|
|
39
|
+
const colorsBlockRe = /colors\s*:\s*\{([^]*?)\}(?:\s*,|\s*\})/g
|
|
40
|
+
let blockMatch: RegExpExecArray | null
|
|
41
|
+
colorsBlockRe.lastIndex = 0
|
|
42
|
+
|
|
43
|
+
while ((blockMatch = colorsBlockRe.exec(cleaned)) !== null) {
|
|
44
|
+
const body = blockMatch[1]
|
|
45
|
+
parseColorBlock(body, '', results, filePath)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return results
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Recursively parse a color block, handling nested objects.
|
|
53
|
+
*/
|
|
54
|
+
function parseColorBlock(
|
|
55
|
+
body: string,
|
|
56
|
+
prefix: string,
|
|
57
|
+
results: Record<string, CSSVariableDefinition>,
|
|
58
|
+
filePath: string,
|
|
59
|
+
): void {
|
|
60
|
+
// Match nested objects: key: { ... }
|
|
61
|
+
const nestedRe = /(\w[\w-]*)\s*:\s*\{([^}]*)\}/g
|
|
62
|
+
const nestedKeys = new Set<string>()
|
|
63
|
+
let nestedMatch: RegExpExecArray | null
|
|
64
|
+
nestedRe.lastIndex = 0
|
|
65
|
+
|
|
66
|
+
while ((nestedMatch = nestedRe.exec(body)) !== null) {
|
|
67
|
+
const key = nestedMatch[1]
|
|
68
|
+
nestedKeys.add(key)
|
|
69
|
+
const nestedBody = nestedMatch[2]
|
|
70
|
+
const nestedPrefix = prefix ? `${prefix}-${kebab(key)}` : kebab(key)
|
|
71
|
+
parseColorBlock(nestedBody, nestedPrefix, results, filePath)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Match flat entries: key: 'value' or key: "value" or key: value
|
|
75
|
+
const entryRe = /(\w[\w-]*)\s*:\s*(?:'([^']*)'|"([^"]*)")/g
|
|
76
|
+
let entryMatch: RegExpExecArray | null
|
|
77
|
+
entryRe.lastIndex = 0
|
|
78
|
+
|
|
79
|
+
while ((entryMatch = entryRe.exec(body)) !== null) {
|
|
80
|
+
const key = entryMatch[1]
|
|
81
|
+
if (nestedKeys.has(key)) continue
|
|
82
|
+
|
|
83
|
+
const value = entryMatch[2] ?? entryMatch[3] ?? ''
|
|
84
|
+
if (!value) continue
|
|
85
|
+
|
|
86
|
+
// Skip non-color values (functions, references)
|
|
87
|
+
if (
|
|
88
|
+
value.includes('(') &&
|
|
89
|
+
!value.startsWith('rgb') &&
|
|
90
|
+
!value.startsWith('hsl') &&
|
|
91
|
+
!value.startsWith('oklch')
|
|
92
|
+
)
|
|
93
|
+
continue
|
|
94
|
+
|
|
95
|
+
const varName = prefix ? `--${prefix}-${kebab(key)}` : `--${kebab(key)}`
|
|
96
|
+
results[varName] = {
|
|
97
|
+
value,
|
|
98
|
+
resolvedValue: value,
|
|
99
|
+
selector: `tailwind:${filePath}`,
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Convert camelCase to kebab-case */
|
|
105
|
+
function kebab(str: string): string {
|
|
106
|
+
return str.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase()
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export async function POST(request: Request): Promise<NextResponse> {
|
|
110
|
+
let body: { projectRoot?: string }
|
|
111
|
+
try {
|
|
112
|
+
body = await request.json()
|
|
113
|
+
} catch {
|
|
114
|
+
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const { projectRoot } = body
|
|
118
|
+
if (!projectRoot || typeof projectRoot !== 'string') {
|
|
119
|
+
return NextResponse.json(
|
|
120
|
+
{ error: 'projectRoot is required' },
|
|
121
|
+
{ status: 400 },
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const rootError = validateProjectRoot(projectRoot)
|
|
126
|
+
if (rootError) {
|
|
127
|
+
return NextResponse.json({ error: rootError }, { status: 400 })
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const resolved = path.resolve(projectRoot)
|
|
131
|
+
|
|
132
|
+
// Find the Tailwind config file
|
|
133
|
+
let configPath: string | null = null
|
|
134
|
+
for (const name of CONFIG_FILES) {
|
|
135
|
+
const candidate = path.join(resolved, name)
|
|
136
|
+
if (existsSync(candidate)) {
|
|
137
|
+
configPath = candidate
|
|
138
|
+
break
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (!configPath) {
|
|
143
|
+
return NextResponse.json({ definitions: {}, count: 0, found: false })
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
const stat = readFileSync(configPath)
|
|
148
|
+
if (stat.length > MAX_FILE_SIZE) {
|
|
149
|
+
return NextResponse.json(
|
|
150
|
+
{ error: 'Config file too large' },
|
|
151
|
+
{ status: 400 },
|
|
152
|
+
)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const content = stat.toString('utf-8')
|
|
156
|
+
const relativePath = path.relative(resolved, configPath)
|
|
157
|
+
const definitions = extractTailwindColors(content, relativePath)
|
|
158
|
+
|
|
159
|
+
return NextResponse.json({
|
|
160
|
+
definitions,
|
|
161
|
+
count: Object.keys(definitions).length,
|
|
162
|
+
found: true,
|
|
163
|
+
configFile: relativePath,
|
|
164
|
+
})
|
|
165
|
+
} catch (error) {
|
|
166
|
+
const message = error instanceof Error ? error.message : 'Unknown error'
|
|
167
|
+
return NextResponse.json(
|
|
168
|
+
{ error: 'Parse failed', details: message },
|
|
169
|
+
{ status: 500 },
|
|
170
|
+
)
|
|
171
|
+
}
|
|
172
|
+
}
|