sonance-brand-mcp 1.3.1 → 1.3.2
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/dist/assets/api/sonance-analyze/route.ts +1116 -0
- package/dist/assets/api/sonance-assets/route.ts +113 -0
- package/dist/assets/api/sonance-components/route.ts +41 -0
- package/dist/assets/api/sonance-inject-id/route.ts +363 -0
- package/dist/assets/api/sonance-save-logo/route.ts +426 -0
- package/dist/assets/api/sonance-theme/route.ts +106 -0
- package/dist/assets/brand-system.ts +1265 -0
- package/dist/assets/components/accordion.stories.tsx +26 -26
- package/dist/assets/components/accordion.tsx +3 -3
- package/dist/assets/components/alert-dialog.stories.tsx +7 -7
- package/dist/assets/components/alert-dialog.tsx +2 -1
- package/dist/assets/components/alert.stories.tsx +3 -3
- package/dist/assets/components/alert.tsx +4 -3
- package/dist/assets/components/aspect-ratio.stories.tsx +4 -1
- package/dist/assets/components/autocomplete.stories.tsx +9 -9
- package/dist/assets/components/autocomplete.tsx +3 -3
- package/dist/assets/components/avatar.stories.tsx +5 -5
- package/dist/assets/components/avatar.tsx +4 -4
- package/dist/assets/components/badge.stories.tsx +10 -10
- package/dist/assets/components/badge.tsx +3 -3
- package/dist/assets/components/breadcrumbs.stories.tsx +7 -7
- package/dist/assets/components/breadcrumbs.tsx +13 -8
- package/dist/assets/components/button.stories.tsx +74 -74
- package/dist/assets/components/button.tsx +2 -0
- package/dist/assets/components/calendar.stories.tsx +11 -11
- package/dist/assets/components/calendar.tsx +4 -4
- package/dist/assets/components/card.stories.tsx +22 -22
- package/dist/assets/components/card.tsx +7 -3
- package/dist/assets/components/carousel.stories.tsx +6 -6
- package/dist/assets/components/carousel.tsx +10 -8
- package/dist/assets/components/chart.tsx +5 -5
- package/dist/assets/components/checkbox-group.stories.tsx +6 -6
- package/dist/assets/components/checkbox-group.tsx +3 -3
- package/dist/assets/components/checkbox.stories.tsx +23 -20
- package/dist/assets/components/checkbox.tsx +13 -16
- package/dist/assets/components/code.stories.tsx +24 -24
- package/dist/assets/components/code.tsx +7 -14
- package/dist/assets/components/collapsible.stories.tsx +3 -3
- package/dist/assets/components/command.stories.tsx +14 -14
- package/dist/assets/components/command.tsx +4 -3
- package/dist/assets/components/context-menu.stories.tsx +1 -1
- package/dist/assets/components/context-menu.tsx +3 -7
- package/dist/assets/components/date-input.stories.tsx +9 -9
- package/dist/assets/components/date-input.tsx +2 -2
- package/dist/assets/components/date-picker.stories.tsx +9 -9
- package/dist/assets/components/date-picker.tsx +3 -3
- package/dist/assets/components/date-range-picker.stories.tsx +12 -12
- package/dist/assets/components/date-range-picker.tsx +3 -3
- package/dist/assets/components/dialog.stories.tsx +40 -40
- package/dist/assets/components/dialog.tsx +8 -12
- package/dist/assets/components/divider.stories.tsx +30 -30
- package/dist/assets/components/divider.tsx +4 -8
- package/dist/assets/components/drawer.stories.tsx +32 -31
- package/dist/assets/components/drawer.tsx +7 -6
- package/dist/assets/components/dropdown-menu.tsx +3 -7
- package/dist/assets/components/dropdown.stories.tsx +12 -12
- package/dist/assets/components/dropdown.tsx +5 -5
- package/dist/assets/components/form.stories.tsx +30 -29
- package/dist/assets/components/form.tsx +5 -5
- package/dist/assets/components/hover-card.stories.tsx +12 -10
- package/dist/assets/components/hover-card.tsx +1 -1
- package/dist/assets/components/image.stories.tsx +48 -25
- package/dist/assets/components/image.tsx +8 -5
- package/dist/assets/components/input-otp.stories.tsx +15 -15
- package/dist/assets/components/input-otp.tsx +5 -5
- package/dist/assets/components/input.stories.tsx +30 -25
- package/dist/assets/components/input.tsx +7 -4
- package/dist/assets/components/kbd.stories.tsx +34 -34
- package/dist/assets/components/kbd.tsx +5 -5
- package/dist/assets/components/link.stories.tsx +36 -36
- package/dist/assets/components/link.tsx +4 -0
- package/dist/assets/components/listbox.stories.tsx +5 -5
- package/dist/assets/components/listbox.tsx +4 -4
- package/dist/assets/components/menubar.tsx +3 -7
- package/dist/assets/components/navbar.stories.tsx +24 -24
- package/dist/assets/components/navbar.tsx +8 -14
- package/dist/assets/components/navigation-menu.stories.tsx +11 -9
- package/dist/assets/components/navigation-menu.tsx +1 -1
- package/dist/assets/components/number-input.stories.tsx +11 -11
- package/dist/assets/components/number-input.tsx +3 -3
- package/dist/assets/components/pagination.stories.tsx +13 -13
- package/dist/assets/components/pagination.tsx +6 -6
- package/dist/assets/components/popover.stories.tsx +35 -35
- package/dist/assets/components/popover.tsx +98 -15
- package/dist/assets/components/progress.stories.tsx +5 -5
- package/dist/assets/components/progress.tsx +5 -5
- package/dist/assets/components/radio-group.stories.tsx +7 -7
- package/dist/assets/components/radio-group.tsx +3 -3
- package/dist/assets/components/range-calendar.stories.tsx +18 -18
- package/dist/assets/components/range-calendar.tsx +3 -3
- package/dist/assets/components/resizable.stories.tsx +23 -23
- package/dist/assets/components/resizable.tsx +1 -1
- package/dist/assets/components/scroll-area.stories.tsx +15 -15
- package/dist/assets/components/scroll-area.tsx +1 -1
- package/dist/assets/components/scroll-shadow.stories.tsx +17 -17
- package/dist/assets/components/scroll-shadow.tsx +2 -2
- package/dist/assets/components/select.stories.tsx +20 -19
- package/dist/assets/components/select.tsx +10 -6
- package/dist/assets/components/separator.tsx +1 -1
- package/dist/assets/components/sheet.tsx +3 -7
- package/dist/assets/components/sidebar.stories.tsx +30 -30
- package/dist/assets/components/sidebar.tsx +24 -27
- package/dist/assets/components/skeleton.stories.tsx +3 -3
- package/dist/assets/components/skeleton.tsx +2 -2
- package/dist/assets/components/slider.stories.tsx +6 -6
- package/dist/assets/components/slider.tsx +3 -3
- package/dist/assets/components/spacer.stories.tsx +11 -11
- package/dist/assets/components/spacer.tsx +2 -2
- package/dist/assets/components/spinner.stories.tsx +8 -8
- package/dist/assets/components/spinner.tsx +5 -5
- package/dist/assets/components/switch.stories.tsx +24 -20
- package/dist/assets/components/switch.tsx +14 -6
- package/dist/assets/components/table.stories.tsx +7 -7
- package/dist/assets/components/table.tsx +8 -8
- package/dist/assets/components/tabs.stories.tsx +37 -37
- package/dist/assets/components/tabs.tsx +3 -3
- package/dist/assets/components/textarea.stories.tsx +13 -12
- package/dist/assets/components/textarea.tsx +3 -3
- package/dist/assets/components/theme-toggle.stories.tsx +31 -30
- package/dist/assets/components/theme-toggle.tsx +2 -2
- package/dist/assets/components/time-input.stories.tsx +16 -16
- package/dist/assets/components/time-input.tsx +2 -2
- package/dist/assets/components/toast.stories.tsx +8 -5
- package/dist/assets/components/toast.tsx +6 -6
- package/dist/assets/components/toggle-group.tsx +1 -1
- package/dist/assets/components/toggle.tsx +1 -1
- package/dist/assets/components/tooltip.stories.tsx +49 -27
- package/dist/assets/components/tooltip.tsx +1 -1
- package/dist/assets/components/user.stories.tsx +23 -23
- package/dist/assets/components/user.tsx +7 -4
- package/dist/assets/dev-tools/SonanceDevTools.tsx +4201 -0
- package/dist/assets/dev-tools/index.ts +10 -0
- package/dist/assets/globals.css +9 -0
- package/dist/assets/styles/brand-overrides.css +37 -0
- package/dist/index.js +1776 -7
- package/package.json +1 -1
|
@@ -0,0 +1,1116 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import * as fs from "fs";
|
|
3
|
+
import * as path from "path";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Sonance DevTools API - Project Analyzer
|
|
7
|
+
*
|
|
8
|
+
* This endpoint scans the codebase to build an index of all design assets:
|
|
9
|
+
* - Logos and images
|
|
10
|
+
* - Theme files
|
|
11
|
+
* - Components using brand elements
|
|
12
|
+
*
|
|
13
|
+
* DEVELOPMENT ONLY.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
// ---- Types ----
|
|
17
|
+
|
|
18
|
+
type ElementCategory = "image" | "text" | "interactive" | "input" | "definition";
|
|
19
|
+
|
|
20
|
+
type ElementType =
|
|
21
|
+
// Images (including common wrapper components)
|
|
22
|
+
| "Image" | "img" | "ZoomImage" | "BrandImage" | "ImageGallery"
|
|
23
|
+
// Text
|
|
24
|
+
| "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "p" | "span"
|
|
25
|
+
// Interactive elements (buttons, links)
|
|
26
|
+
| "Button" | "Link" | "a"
|
|
27
|
+
// Inputs
|
|
28
|
+
| "Input" | "Textarea" | "Select" | "Checkbox" | "Switch"
|
|
29
|
+
// Component definitions
|
|
30
|
+
| "ComponentDefinition";
|
|
31
|
+
|
|
32
|
+
interface ScannedElement {
|
|
33
|
+
id: string; // Unique ID for this occurrence
|
|
34
|
+
filePath: string; // Relative path to the file
|
|
35
|
+
lineNumber: number; // Line number in the file
|
|
36
|
+
index: number; // Absolute character index in the file (for precise targeting)
|
|
37
|
+
category: ElementCategory; // Category of element
|
|
38
|
+
elementType: ElementType; // Specific element type
|
|
39
|
+
hasId: boolean; // Whether element already has an id attribute
|
|
40
|
+
existingId?: string; // The existing id if present
|
|
41
|
+
suggestedId?: string; // Suggested ID based on context
|
|
42
|
+
context: {
|
|
43
|
+
parentComponent?: string; // Parent component name if detectable
|
|
44
|
+
semanticContainer?: string; // header/footer/nav/aside/main/section
|
|
45
|
+
};
|
|
46
|
+
// Image-specific fields
|
|
47
|
+
srcValue?: string; // The src prop value (for images)
|
|
48
|
+
srcType?: "literal" | "variable" | "import"; // How the src is defined
|
|
49
|
+
alt?: string; // Alt text if present
|
|
50
|
+
// Text-specific fields
|
|
51
|
+
textContent?: string; // Truncated text content (for headings/paragraphs)
|
|
52
|
+
// Component-specific fields
|
|
53
|
+
variant?: string; // variant prop if present
|
|
54
|
+
// Input-specific fields
|
|
55
|
+
inputName?: string; // name or placeholder if present
|
|
56
|
+
inputType?: string; // type attribute if present
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Backwards compatibility alias
|
|
60
|
+
type ImageElement = ScannedElement;
|
|
61
|
+
|
|
62
|
+
interface ThemeFile {
|
|
63
|
+
filePath: string;
|
|
64
|
+
type: "css" | "tailwind" | "config";
|
|
65
|
+
hasBrandVariables: boolean;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Color source detection for universal DevTools
|
|
69
|
+
interface ColorSource {
|
|
70
|
+
filePath: string;
|
|
71
|
+
type: "css-variables" | "tailwind-config" | "theme-file" | "hardcoded";
|
|
72
|
+
variables: {
|
|
73
|
+
name: string; // e.g., "--primary", "primary", "colors.primary"
|
|
74
|
+
value: string; // e.g., "#333F48"
|
|
75
|
+
lineNumber: number;
|
|
76
|
+
}[];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
interface ColorArchitecture {
|
|
80
|
+
primary: "css-variables" | "tailwind" | "hardcoded" | "unknown";
|
|
81
|
+
accent: "css-variables" | "tailwind" | "hardcoded" | "unknown";
|
|
82
|
+
sources: ColorSource[];
|
|
83
|
+
recommendation: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
interface CategorySummary {
|
|
87
|
+
total: number;
|
|
88
|
+
withId: number;
|
|
89
|
+
missingId: number;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
interface AnalysisResult {
|
|
93
|
+
timestamp: string;
|
|
94
|
+
scanDuration: number;
|
|
95
|
+
filesScanned: number;
|
|
96
|
+
elements: ScannedElement[];
|
|
97
|
+
// Backwards compatibility
|
|
98
|
+
images: ScannedElement[];
|
|
99
|
+
themeFiles: ThemeFile[];
|
|
100
|
+
colorArchitecture: ColorArchitecture;
|
|
101
|
+
summary: {
|
|
102
|
+
totalElements: number;
|
|
103
|
+
elementsWithId: number;
|
|
104
|
+
elementsMissingId: number;
|
|
105
|
+
byCategory: {
|
|
106
|
+
image: CategorySummary;
|
|
107
|
+
text: CategorySummary;
|
|
108
|
+
interactive: CategorySummary;
|
|
109
|
+
input: CategorySummary;
|
|
110
|
+
definition: CategorySummary;
|
|
111
|
+
};
|
|
112
|
+
// Legacy fields
|
|
113
|
+
totalImages: number;
|
|
114
|
+
imagesWithId: number;
|
|
115
|
+
imagesMissingId: number;
|
|
116
|
+
brandLogosDetected: number;
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ---- AST-like Parsing (Regex-based for simplicity) ----
|
|
121
|
+
|
|
122
|
+
// Element type to category mapping
|
|
123
|
+
const ELEMENT_CATEGORIES: Record<string, ElementCategory> = {
|
|
124
|
+
// Images (including common wrapper components)
|
|
125
|
+
"Image": "image",
|
|
126
|
+
"img": "image",
|
|
127
|
+
"ZoomImage": "image",
|
|
128
|
+
"BrandImage": "image",
|
|
129
|
+
"ImageGallery": "image",
|
|
130
|
+
// Text
|
|
131
|
+
"h1": "text", "h2": "text", "h3": "text", "h4": "text", "h5": "text", "h6": "text",
|
|
132
|
+
"p": "text", "span": "text",
|
|
133
|
+
// Interactive elements (buttons, links)
|
|
134
|
+
"Button": "interactive", "Link": "interactive", "a": "interactive",
|
|
135
|
+
// Inputs
|
|
136
|
+
"Input": "input", "Textarea": "input", "Select": "input",
|
|
137
|
+
"Checkbox": "input", "Switch": "input",
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
// All element tags to scan for
|
|
141
|
+
const ALL_ELEMENT_TAGS = Object.keys(ELEMENT_CATEGORIES);
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Extracts all design-related elements from a file using pattern matching.
|
|
145
|
+
* This is a simplified AST approach that handles common patterns.
|
|
146
|
+
*/
|
|
147
|
+
function extractElements(
|
|
148
|
+
filePath: string,
|
|
149
|
+
content: string,
|
|
150
|
+
relativePath: string
|
|
151
|
+
): ScannedElement[] {
|
|
152
|
+
const elements: ScannedElement[] = [];
|
|
153
|
+
const lines = content.split("\n");
|
|
154
|
+
|
|
155
|
+
// Track component context
|
|
156
|
+
let currentComponent: string | undefined;
|
|
157
|
+
|
|
158
|
+
// Pattern for function/const component declarations
|
|
159
|
+
const componentPattern = /(?:function|const)\s+([A-Z][a-zA-Z0-9]*)/;
|
|
160
|
+
|
|
161
|
+
const fullContent = content;
|
|
162
|
+
|
|
163
|
+
// Build a regex to match all element types
|
|
164
|
+
// Need to escape for regex and handle case sensitivity
|
|
165
|
+
// React components are PascalCase, HTML elements are lowercase
|
|
166
|
+
const tagNames = ALL_ELEMENT_TAGS.join("|");
|
|
167
|
+
const elementTagPattern = new RegExp(`<(${tagNames})(?:\\s|>|\\/)`, "g");
|
|
168
|
+
|
|
169
|
+
let tagMatch;
|
|
170
|
+
|
|
171
|
+
while ((tagMatch = elementTagPattern.exec(fullContent)) !== null) {
|
|
172
|
+
const tagStart = tagMatch.index;
|
|
173
|
+
const elementType = tagMatch[1] as ElementType;
|
|
174
|
+
const category = ELEMENT_CATEGORIES[elementType];
|
|
175
|
+
|
|
176
|
+
if (!category) continue;
|
|
177
|
+
|
|
178
|
+
// Find the end of this element's opening tag (closing > or />)
|
|
179
|
+
let elementEnd = tagStart;
|
|
180
|
+
let inString = false;
|
|
181
|
+
let stringChar = "";
|
|
182
|
+
let inJsxExpr = 0;
|
|
183
|
+
|
|
184
|
+
for (let i = tagStart; i < fullContent.length; i++) {
|
|
185
|
+
const char = fullContent[i];
|
|
186
|
+
const prevChar = i > 0 ? fullContent[i - 1] : "";
|
|
187
|
+
|
|
188
|
+
// Track string boundaries
|
|
189
|
+
if ((char === '"' || char === "'" || char === '`') && prevChar !== '\\') {
|
|
190
|
+
if (!inString) {
|
|
191
|
+
inString = true;
|
|
192
|
+
stringChar = char;
|
|
193
|
+
} else if (char === stringChar) {
|
|
194
|
+
inString = false;
|
|
195
|
+
stringChar = "";
|
|
196
|
+
}
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (inString) continue;
|
|
201
|
+
|
|
202
|
+
// Track JSX expression boundaries
|
|
203
|
+
if (char === '{') {
|
|
204
|
+
inJsxExpr++;
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
if (char === '}') {
|
|
208
|
+
inJsxExpr--;
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (inJsxExpr > 0) continue;
|
|
213
|
+
|
|
214
|
+
// Check for self-closing or regular closing
|
|
215
|
+
if (char === '/' && fullContent[i + 1] === '>') {
|
|
216
|
+
elementEnd = i + 2;
|
|
217
|
+
break;
|
|
218
|
+
}
|
|
219
|
+
if (char === '>') {
|
|
220
|
+
elementEnd = i + 1;
|
|
221
|
+
break;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const elementContent = fullContent.substring(tagStart, elementEnd);
|
|
226
|
+
|
|
227
|
+
// Calculate line number
|
|
228
|
+
const lineNumber = fullContent.substring(0, tagStart).split("\n").length;
|
|
229
|
+
|
|
230
|
+
// Extract id attribute
|
|
231
|
+
let hasId = false;
|
|
232
|
+
let existingId: string | undefined;
|
|
233
|
+
const idMatch = elementContent.match(/\bid=["']([^"']+)["']/);
|
|
234
|
+
if (idMatch) {
|
|
235
|
+
hasId = true;
|
|
236
|
+
existingId = idMatch[1];
|
|
237
|
+
}
|
|
238
|
+
const idExprMatch = elementContent.match(/\bid=\{["']([^"']+)["']\}/);
|
|
239
|
+
if (idExprMatch) {
|
|
240
|
+
hasId = true;
|
|
241
|
+
existingId = idExprMatch[1];
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Find semantic container by looking backwards in the content
|
|
245
|
+
let semanticContainer: string | undefined;
|
|
246
|
+
const beforeElement = fullContent.substring(Math.max(0, tagStart - 500), tagStart);
|
|
247
|
+
const semanticMatch = beforeElement.match(/<(header|footer|nav|aside|main|section)[^>]*>/gi);
|
|
248
|
+
if (semanticMatch) {
|
|
249
|
+
const lastMatch = semanticMatch[semanticMatch.length - 1];
|
|
250
|
+
const containerMatch = lastMatch.match(/<(header|footer|nav|aside|main|section)/i);
|
|
251
|
+
if (containerMatch) {
|
|
252
|
+
semanticContainer = containerMatch[1].toLowerCase();
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Find component name
|
|
257
|
+
for (let i = lineNumber - 1; i >= 0 && i >= lineNumber - 50; i--) {
|
|
258
|
+
const line = lines[i];
|
|
259
|
+
const compMatch = line.match(componentPattern);
|
|
260
|
+
if (compMatch) {
|
|
261
|
+
currentComponent = compMatch[1];
|
|
262
|
+
break;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Extract category-specific fields
|
|
267
|
+
const element: ScannedElement = {
|
|
268
|
+
id: `${relativePath}:${lineNumber}:${elementType}:${tagStart}`,
|
|
269
|
+
filePath: relativePath,
|
|
270
|
+
lineNumber,
|
|
271
|
+
index: tagStart, // Absolute character index for precise targeting
|
|
272
|
+
category,
|
|
273
|
+
elementType,
|
|
274
|
+
hasId,
|
|
275
|
+
existingId,
|
|
276
|
+
context: {
|
|
277
|
+
parentComponent: currentComponent,
|
|
278
|
+
semanticContainer,
|
|
279
|
+
},
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
// Image-specific extraction
|
|
283
|
+
if (category === "image") {
|
|
284
|
+
// Extract src attribute
|
|
285
|
+
const literalSrcMatch = elementContent.match(/src=["']([^"']+)["']/);
|
|
286
|
+
if (literalSrcMatch) {
|
|
287
|
+
element.srcValue = literalSrcMatch[1];
|
|
288
|
+
element.srcType = "literal";
|
|
289
|
+
} else {
|
|
290
|
+
const exprSrcMatch = elementContent.match(/src=\{([^}]+)\}/);
|
|
291
|
+
if (exprSrcMatch) {
|
|
292
|
+
element.srcValue = exprSrcMatch[1].trim();
|
|
293
|
+
element.srcType = element.srcValue.includes("import") || /^[A-Z]/.test(element.srcValue)
|
|
294
|
+
? "import" : "variable";
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Extract alt text
|
|
299
|
+
const altMatch = elementContent.match(/alt=["']([^"']+)["']/);
|
|
300
|
+
if (altMatch) {
|
|
301
|
+
element.alt = altMatch[1];
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Text-specific extraction
|
|
306
|
+
if (category === "text") {
|
|
307
|
+
// Try to extract text content between opening and closing tags
|
|
308
|
+
// This is approximate - we look for content after the opening tag
|
|
309
|
+
const afterOpenTag = fullContent.substring(elementEnd);
|
|
310
|
+
const closeTagMatch = afterOpenTag.match(new RegExp(`^([^<]*)<\\/${elementType}>`));
|
|
311
|
+
if (closeTagMatch && closeTagMatch[1].trim()) {
|
|
312
|
+
element.textContent = closeTagMatch[1].trim().substring(0, 50);
|
|
313
|
+
if (closeTagMatch[1].trim().length > 50) {
|
|
314
|
+
element.textContent += "...";
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Component-specific extraction
|
|
320
|
+
if (category === "component") {
|
|
321
|
+
// Extract variant
|
|
322
|
+
const variantMatch = elementContent.match(/variant=["']([^"']+)["']/);
|
|
323
|
+
if (variantMatch) {
|
|
324
|
+
element.variant = variantMatch[1];
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Input-specific extraction
|
|
329
|
+
if (category === "input") {
|
|
330
|
+
// Extract name or placeholder
|
|
331
|
+
const nameMatch = elementContent.match(/(?:name|placeholder)=["']([^"']+)["']/);
|
|
332
|
+
if (nameMatch) {
|
|
333
|
+
element.inputName = nameMatch[1];
|
|
334
|
+
}
|
|
335
|
+
// Extract type
|
|
336
|
+
const typeMatch = elementContent.match(/type=["']([^"']+)["']/);
|
|
337
|
+
if (typeMatch) {
|
|
338
|
+
element.inputType = typeMatch[1];
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Generate suggested ID
|
|
343
|
+
element.suggestedId = generateSuggestedId(element);
|
|
344
|
+
|
|
345
|
+
elements.push(element);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
return elements;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Legacy function for backwards compatibility
|
|
353
|
+
*/
|
|
354
|
+
function extractImageElements(
|
|
355
|
+
filePath: string,
|
|
356
|
+
content: string,
|
|
357
|
+
relativePath: string
|
|
358
|
+
): ScannedElement[] {
|
|
359
|
+
return extractElements(filePath, content, relativePath)
|
|
360
|
+
.filter(el => el.category === "image");
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Generates a suggested ID based on element and available context
|
|
365
|
+
*/
|
|
366
|
+
function generateSuggestedId(element: ScannedElement): string {
|
|
367
|
+
const parts: string[] = [];
|
|
368
|
+
const { category, elementType, context, srcValue, alt, textContent, variant, inputName } = element;
|
|
369
|
+
|
|
370
|
+
// Add semantic container if available
|
|
371
|
+
if (context.semanticContainer) {
|
|
372
|
+
parts.push(context.semanticContainer);
|
|
373
|
+
} else if (context.parentComponent) {
|
|
374
|
+
// Use component name if no semantic container
|
|
375
|
+
parts.push(context.parentComponent.replace(/([A-Z])/g, "-$1").toLowerCase().replace(/^-/, ""));
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Category-specific ID generation
|
|
379
|
+
switch (category) {
|
|
380
|
+
case "image": {
|
|
381
|
+
const src = srcValue || "";
|
|
382
|
+
const srcLower = src.toLowerCase();
|
|
383
|
+
|
|
384
|
+
// Try to extract brand from src
|
|
385
|
+
if (srcLower.includes("sonance")) {
|
|
386
|
+
parts.push("sonance");
|
|
387
|
+
} else if (srcLower.includes("iport")) {
|
|
388
|
+
parts.push("iport");
|
|
389
|
+
} else if (srcLower.includes("blaze")) {
|
|
390
|
+
parts.push("blaze");
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Add "logo" if it looks like a logo
|
|
394
|
+
if (srcLower.includes("logo") || (alt && alt.toLowerCase().includes("logo"))) {
|
|
395
|
+
parts.push("logo");
|
|
396
|
+
} else if (alt) {
|
|
397
|
+
const cleanAlt = alt.toLowerCase()
|
|
398
|
+
.replace(/[^a-z0-9\s]/g, "")
|
|
399
|
+
.replace(/\s+/g, "-")
|
|
400
|
+
.substring(0, 20);
|
|
401
|
+
if (cleanAlt) parts.push(cleanAlt);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
if (parts.length === 0) parts.push("image");
|
|
405
|
+
break;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
case "text": {
|
|
409
|
+
// Add element type (h1, h2, p, etc.)
|
|
410
|
+
parts.push(elementType.toLowerCase());
|
|
411
|
+
|
|
412
|
+
// Try to use text content for context
|
|
413
|
+
if (textContent) {
|
|
414
|
+
const cleanText = textContent.toLowerCase()
|
|
415
|
+
.replace(/[^a-z0-9\s]/g, "")
|
|
416
|
+
.replace(/\s+/g, "-")
|
|
417
|
+
.substring(0, 20);
|
|
418
|
+
if (cleanText) parts.push(cleanText);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
if (parts.length === 1) parts.push("title");
|
|
422
|
+
break;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
case "component": {
|
|
426
|
+
// Add element type (button, link, etc.)
|
|
427
|
+
parts.push(elementType.toLowerCase());
|
|
428
|
+
|
|
429
|
+
// Add variant if present
|
|
430
|
+
if (variant) {
|
|
431
|
+
parts.push(variant.toLowerCase());
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
if (parts.length === 1) parts.push("action");
|
|
435
|
+
break;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
case "input": {
|
|
439
|
+
// Add element type
|
|
440
|
+
parts.push(elementType.toLowerCase());
|
|
441
|
+
|
|
442
|
+
// Add name/placeholder if present
|
|
443
|
+
if (inputName) {
|
|
444
|
+
const cleanName = inputName.toLowerCase()
|
|
445
|
+
.replace(/[^a-z0-9\s]/g, "")
|
|
446
|
+
.replace(/\s+/g, "-")
|
|
447
|
+
.substring(0, 15);
|
|
448
|
+
if (cleanName) parts.push(cleanName);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
if (parts.length === 1) parts.push("field");
|
|
452
|
+
break;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// Deduplicate and join
|
|
457
|
+
return parts.filter((v, i, a) => a.indexOf(v) === i).join("-");
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Detects theme-related files
|
|
462
|
+
*/
|
|
463
|
+
function detectThemeFiles(
|
|
464
|
+
filePath: string,
|
|
465
|
+
content: string,
|
|
466
|
+
relativePath: string
|
|
467
|
+
): ThemeFile | null {
|
|
468
|
+
const filename = path.basename(filePath).toLowerCase();
|
|
469
|
+
|
|
470
|
+
// Check for theme-related filenames
|
|
471
|
+
const isThemeFile =
|
|
472
|
+
filename.includes("theme") ||
|
|
473
|
+
filename.includes("globals") ||
|
|
474
|
+
filename.includes("tailwind") ||
|
|
475
|
+
filename.includes("brand");
|
|
476
|
+
|
|
477
|
+
if (!isThemeFile) return null;
|
|
478
|
+
|
|
479
|
+
// Determine type
|
|
480
|
+
let type: ThemeFile["type"] = "css";
|
|
481
|
+
if (filename.includes("tailwind")) {
|
|
482
|
+
type = "tailwind";
|
|
483
|
+
} else if (filename.endsWith(".ts") || filename.endsWith(".js") || filename.endsWith(".json")) {
|
|
484
|
+
type = "config";
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// Check for brand variables
|
|
488
|
+
const hasBrandVariables =
|
|
489
|
+
content.includes("--sonance") ||
|
|
490
|
+
content.includes("--iport") ||
|
|
491
|
+
content.includes("--blaze") ||
|
|
492
|
+
content.includes("sonance-") ||
|
|
493
|
+
content.includes("brandColors") ||
|
|
494
|
+
content.includes("brandLogos");
|
|
495
|
+
|
|
496
|
+
return {
|
|
497
|
+
filePath: relativePath,
|
|
498
|
+
type,
|
|
499
|
+
hasBrandVariables,
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Detects color variable definitions in a file
|
|
505
|
+
*/
|
|
506
|
+
function detectColorSources(
|
|
507
|
+
filePath: string,
|
|
508
|
+
content: string,
|
|
509
|
+
relativePath: string
|
|
510
|
+
): ColorSource | null {
|
|
511
|
+
const filename = path.basename(filePath).toLowerCase();
|
|
512
|
+
const variables: ColorSource["variables"] = [];
|
|
513
|
+
const lines = content.split('\n');
|
|
514
|
+
|
|
515
|
+
// Pattern 1: CSS custom properties (:root { --primary: #xxx })
|
|
516
|
+
const cssVarPattern = /--(primary|accent|color-primary|color-accent|theme-primary|theme-accent|sonance-\w+|iport-\w+|blaze-\w+)\s*:\s*(#[0-9a-fA-F]{3,8}|rgb[a]?\([^)]+\))/gi;
|
|
517
|
+
let match;
|
|
518
|
+
|
|
519
|
+
while ((match = cssVarPattern.exec(content)) !== null) {
|
|
520
|
+
const lineNumber = content.substring(0, match.index).split('\n').length;
|
|
521
|
+
variables.push({
|
|
522
|
+
name: `--${match[1]}`,
|
|
523
|
+
value: match[2],
|
|
524
|
+
lineNumber,
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// Pattern 2: Tailwind config colors
|
|
529
|
+
if (filename.includes('tailwind.config')) {
|
|
530
|
+
const tailwindColorPattern = /(primary|accent|brand)\s*:\s*['"]?(#[0-9a-fA-F]{3,8})['"]?/gi;
|
|
531
|
+
while ((match = tailwindColorPattern.exec(content)) !== null) {
|
|
532
|
+
const lineNumber = content.substring(0, match.index).split('\n').length;
|
|
533
|
+
variables.push({
|
|
534
|
+
name: match[1],
|
|
535
|
+
value: match[2],
|
|
536
|
+
lineNumber,
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// Pattern 3: JS/TS theme objects (e.g., const colors = { primary: "#xxx" })
|
|
542
|
+
if (filename.endsWith('.ts') || filename.endsWith('.js') || filename.endsWith('.tsx')) {
|
|
543
|
+
const jsColorPattern = /(primary|accent|baseColor|accentColor)\s*[:=]\s*['"`](#[0-9a-fA-F]{3,8})['"`]/gi;
|
|
544
|
+
while ((match = jsColorPattern.exec(content)) !== null) {
|
|
545
|
+
const lineNumber = content.substring(0, match.index).split('\n').length;
|
|
546
|
+
variables.push({
|
|
547
|
+
name: match[1],
|
|
548
|
+
value: match[2],
|
|
549
|
+
lineNumber,
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
if (variables.length === 0) return null;
|
|
555
|
+
|
|
556
|
+
// Determine type
|
|
557
|
+
let type: ColorSource["type"] = "hardcoded";
|
|
558
|
+
if (filename.endsWith('.css') && variables.some(v => v.name.startsWith('--'))) {
|
|
559
|
+
type = "css-variables";
|
|
560
|
+
} else if (filename.includes('tailwind.config')) {
|
|
561
|
+
type = "tailwind-config";
|
|
562
|
+
} else if (filename.includes('theme') || filename.includes('brand') || filename.includes('colors')) {
|
|
563
|
+
type = "theme-file";
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
return {
|
|
567
|
+
filePath: relativePath,
|
|
568
|
+
type,
|
|
569
|
+
variables,
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* Analyzes color architecture from detected sources
|
|
575
|
+
*/
|
|
576
|
+
function analyzeColorArchitecture(sources: ColorSource[]): ColorArchitecture {
|
|
577
|
+
let primary: ColorArchitecture["primary"] = "unknown";
|
|
578
|
+
let accent: ColorArchitecture["accent"] = "unknown";
|
|
579
|
+
let recommendation = "No standard color architecture detected. Changes will be previewed but may need manual persistence.";
|
|
580
|
+
|
|
581
|
+
// Check for CSS variables
|
|
582
|
+
const cssVarSources = sources.filter(s => s.type === "css-variables");
|
|
583
|
+
if (cssVarSources.length > 0) {
|
|
584
|
+
const hasPrimary = cssVarSources.some(s => s.variables.some(v => v.name.includes('primary')));
|
|
585
|
+
const hasAccent = cssVarSources.some(s => s.variables.some(v => v.name.includes('accent')));
|
|
586
|
+
|
|
587
|
+
if (hasPrimary) primary = "css-variables";
|
|
588
|
+
if (hasAccent) accent = "css-variables";
|
|
589
|
+
|
|
590
|
+
if (hasPrimary || hasAccent) {
|
|
591
|
+
recommendation = "CSS custom properties detected. Color changes can be saved to your CSS files.";
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// Check for Tailwind config
|
|
596
|
+
const tailwindSources = sources.filter(s => s.type === "tailwind-config");
|
|
597
|
+
if (tailwindSources.length > 0) {
|
|
598
|
+
if (primary === "unknown") primary = "tailwind";
|
|
599
|
+
if (accent === "unknown") accent = "tailwind";
|
|
600
|
+
recommendation = "Tailwind config detected. Color changes can be saved to tailwind.config.";
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// Check for theme files
|
|
604
|
+
const themeSources = sources.filter(s => s.type === "theme-file");
|
|
605
|
+
if (themeSources.length > 0 && primary === "unknown") {
|
|
606
|
+
primary = "hardcoded";
|
|
607
|
+
accent = "hardcoded";
|
|
608
|
+
recommendation = "Theme configuration file detected. Color changes can be saved to your theme file.";
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
return {
|
|
612
|
+
primary,
|
|
613
|
+
accent,
|
|
614
|
+
sources,
|
|
615
|
+
recommendation,
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* Recursively scans directory for source files
|
|
621
|
+
*/
|
|
622
|
+
function scanDirectory(
|
|
623
|
+
dir: string,
|
|
624
|
+
projectRoot: string,
|
|
625
|
+
extensions: string[] = [".tsx", ".jsx", ".js", ".ts", ".css"]
|
|
626
|
+
): { elements: ScannedElement[]; images: ScannedElement[]; themeFiles: ThemeFile[]; colorSources: ColorSource[]; filesScanned: number } {
|
|
627
|
+
const elements: ScannedElement[] = [];
|
|
628
|
+
const themeFiles: ThemeFile[] = [];
|
|
629
|
+
const colorSources: ColorSource[] = [];
|
|
630
|
+
let filesScanned = 0;
|
|
631
|
+
|
|
632
|
+
function scan(currentDir: string): void {
|
|
633
|
+
if (!fs.existsSync(currentDir)) return;
|
|
634
|
+
|
|
635
|
+
const entries = fs.readdirSync(currentDir, { withFileTypes: true });
|
|
636
|
+
|
|
637
|
+
for (const entry of entries) {
|
|
638
|
+
const fullPath = path.join(currentDir, entry.name);
|
|
639
|
+
|
|
640
|
+
// Skip excluded directories
|
|
641
|
+
if (entry.isDirectory()) {
|
|
642
|
+
if (
|
|
643
|
+
entry.name.startsWith(".") ||
|
|
644
|
+
entry.name === "node_modules" ||
|
|
645
|
+
entry.name === "dist" ||
|
|
646
|
+
entry.name === "build" ||
|
|
647
|
+
entry.name === ".next"
|
|
648
|
+
) {
|
|
649
|
+
continue;
|
|
650
|
+
}
|
|
651
|
+
scan(fullPath);
|
|
652
|
+
} else if (entry.isFile() && extensions.some(ext => entry.name.endsWith(ext))) {
|
|
653
|
+
try {
|
|
654
|
+
const content = fs.readFileSync(fullPath, "utf-8");
|
|
655
|
+
const relativePath = fullPath.replace(projectRoot, "").replace(/^\//, "");
|
|
656
|
+
filesScanned++;
|
|
657
|
+
|
|
658
|
+
// Extract all elements from JSX/TSX files
|
|
659
|
+
if (entry.name.endsWith(".tsx") || entry.name.endsWith(".jsx")) {
|
|
660
|
+
const fileElements = extractElements(fullPath, content, relativePath);
|
|
661
|
+
elements.push(...fileElements);
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// Detect component definitions (files in src/components/ui or src/components/layout)
|
|
665
|
+
if (entry.name.endsWith(".tsx") && !entry.name.endsWith(".stories.tsx")) {
|
|
666
|
+
const isComponentDef =
|
|
667
|
+
relativePath.startsWith("src/components/ui/") ||
|
|
668
|
+
relativePath.startsWith("src/components/layout/");
|
|
669
|
+
|
|
670
|
+
if (isComponentDef) {
|
|
671
|
+
const componentName = entry.name.replace(".tsx", "");
|
|
672
|
+
const hasTag = content.includes("data-sonance-name");
|
|
673
|
+
|
|
674
|
+
// Create a definition element for this component file
|
|
675
|
+
const defElement: ScannedElement = {
|
|
676
|
+
id: `${relativePath}:1:ComponentDefinition`,
|
|
677
|
+
filePath: relativePath,
|
|
678
|
+
lineNumber: 1,
|
|
679
|
+
index: 0,
|
|
680
|
+
category: "definition",
|
|
681
|
+
elementType: "ComponentDefinition",
|
|
682
|
+
hasId: hasTag, // "hasId" here means "has data-sonance-name"
|
|
683
|
+
existingId: hasTag ? componentName : undefined,
|
|
684
|
+
suggestedId: componentName.toLowerCase(),
|
|
685
|
+
context: {
|
|
686
|
+
parentComponent: componentName,
|
|
687
|
+
},
|
|
688
|
+
};
|
|
689
|
+
elements.push(defElement);
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// Detect theme files
|
|
694
|
+
const themeFile = detectThemeFiles(fullPath, content, relativePath);
|
|
695
|
+
if (themeFile) {
|
|
696
|
+
themeFiles.push(themeFile);
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
// Detect color sources
|
|
700
|
+
const colorSource = detectColorSources(fullPath, content, relativePath);
|
|
701
|
+
if (colorSource) {
|
|
702
|
+
colorSources.push(colorSource);
|
|
703
|
+
}
|
|
704
|
+
} catch (error) {
|
|
705
|
+
// Skip files that can't be read
|
|
706
|
+
console.error(`Error reading ${fullPath}:`, error);
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
scan(dir);
|
|
713
|
+
|
|
714
|
+
// Backwards compatibility: filter images
|
|
715
|
+
const images = elements.filter(el => el.category === "image");
|
|
716
|
+
|
|
717
|
+
return { elements, images, themeFiles, colorSources, filesScanned };
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// ---- Component Definition Tagging ----
|
|
721
|
+
|
|
722
|
+
/**
|
|
723
|
+
* Tags a component definition file with data-sonance-name attribute.
|
|
724
|
+
* Uses multiple regex patterns to handle different component structures.
|
|
725
|
+
*/
|
|
726
|
+
function tagComponentDefinition(filePath: string, content: string, componentName: string): { success: boolean; content: string; error?: string } {
|
|
727
|
+
// Helper to inject tag only if not present in the match
|
|
728
|
+
const injectTag = (match: string, p1: string, p2: string) => {
|
|
729
|
+
if (match.includes("data-sonance-name=")) return match;
|
|
730
|
+
return `${p1} data-sonance-name="${componentName}"${p2}`;
|
|
731
|
+
};
|
|
732
|
+
|
|
733
|
+
let modified = false;
|
|
734
|
+
let result = content;
|
|
735
|
+
|
|
736
|
+
// Pattern 1: <ElementName {...props} or <ElementName className={...} {...props}
|
|
737
|
+
const propsSpreadPattern = /(<(?:[A-Z][a-zA-Z0-9]*|[a-z]+)(?:\s+[^>]*?)?)(\s+\{\.\.\.props\})/g;
|
|
738
|
+
if (propsSpreadPattern.test(result)) {
|
|
739
|
+
propsSpreadPattern.lastIndex = 0;
|
|
740
|
+
const newContent = result.replace(propsSpreadPattern, injectTag);
|
|
741
|
+
if (newContent !== result) {
|
|
742
|
+
result = newContent;
|
|
743
|
+
modified = true;
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
// Pattern 2: forwardRef with explicit return
|
|
748
|
+
if (!modified) {
|
|
749
|
+
const forwardRefReturnPattern = /(return\s*\(\s*<(?:[A-Z][a-zA-Z0-9]*|[a-z]+))(?![^>]*data-sonance-name=)(\s+)/;
|
|
750
|
+
if (forwardRefReturnPattern.test(result)) {
|
|
751
|
+
const newContent = result.replace(forwardRefReturnPattern, injectTag);
|
|
752
|
+
if (newContent !== result) {
|
|
753
|
+
result = newContent;
|
|
754
|
+
modified = true;
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// Pattern 3: Simple component that returns a single element
|
|
760
|
+
if (!modified) {
|
|
761
|
+
const simpleReturnPattern = /(return\s+<(?:[A-Z][a-zA-Z0-9]*|[a-z]+))(?![^>]*data-sonance-name=)(\s+)/;
|
|
762
|
+
if (simpleReturnPattern.test(result)) {
|
|
763
|
+
const newContent = result.replace(simpleReturnPattern, injectTag);
|
|
764
|
+
if (newContent !== result) {
|
|
765
|
+
result = newContent;
|
|
766
|
+
modified = true;
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
// Pattern 4: Semantic HTML tags
|
|
772
|
+
if (!modified) {
|
|
773
|
+
const semanticTagPattern = /(<(?:aside|nav|main|header|footer|section|article)(?:\s+[^>]*?)?)(\s+className=)/;
|
|
774
|
+
if (semanticTagPattern.test(result)) {
|
|
775
|
+
const newContent = result.replace(semanticTagPattern, injectTag);
|
|
776
|
+
if (newContent !== result) {
|
|
777
|
+
result = newContent;
|
|
778
|
+
modified = true;
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
// Pattern 5: div with className={cn( - common Shadcn pattern
|
|
784
|
+
if (!modified) {
|
|
785
|
+
const cnDivPattern = /(<div)(?![^>]*data-sonance-name=)(\s+className=\{cn\()/;
|
|
786
|
+
if (cnDivPattern.test(result)) {
|
|
787
|
+
const newContent = result.replace(cnDivPattern, injectTag);
|
|
788
|
+
if (newContent !== result) {
|
|
789
|
+
result = newContent;
|
|
790
|
+
modified = true;
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
// Pattern 6: forwardRef with implicit return (arrow function + parentheses)
|
|
796
|
+
if (!modified) {
|
|
797
|
+
const forwardRefImplicitPattern = /(\)\s*=>\s*\(\s*<(?:[A-Z][a-zA-Z0-9]*\.)?[A-Z][a-zA-Z0-9]*)(?![^>]*data-sonance-name=)(\s+)/;
|
|
798
|
+
if (forwardRefImplicitPattern.test(result)) {
|
|
799
|
+
const newContent = result.replace(forwardRefImplicitPattern, injectTag);
|
|
800
|
+
if (newContent !== result) {
|
|
801
|
+
result = newContent;
|
|
802
|
+
modified = true;
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
// Pattern 7: Arrow function component with implicit return
|
|
808
|
+
if (!modified) {
|
|
809
|
+
const arrowImplicitPattern = /(}\)\s*=>\s*\(\s*<(?:[A-Z][a-zA-Z0-9]*\.)?[A-Z][a-zA-Z0-9]*)(?![^>]*data-sonance-name=)(\s+)/;
|
|
810
|
+
if (arrowImplicitPattern.test(result)) {
|
|
811
|
+
const newContent = result.replace(arrowImplicitPattern, injectTag);
|
|
812
|
+
if (newContent !== result) {
|
|
813
|
+
result = newContent;
|
|
814
|
+
modified = true;
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
if (modified) {
|
|
820
|
+
return { success: true, content: result };
|
|
821
|
+
} else {
|
|
822
|
+
return { success: false, content: result, error: "No matching pattern found (may be pure re-export)" };
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
// ---- API Handlers ----
|
|
827
|
+
|
|
828
|
+
export async function GET() {
|
|
829
|
+
// Security: Only allow in development
|
|
830
|
+
if (process.env.NODE_ENV !== "development") {
|
|
831
|
+
return NextResponse.json(
|
|
832
|
+
{ error: "This endpoint is only available in development mode." },
|
|
833
|
+
{ status: 403 }
|
|
834
|
+
);
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
const startTime = Date.now();
|
|
838
|
+
|
|
839
|
+
try {
|
|
840
|
+
const projectRoot = process.cwd();
|
|
841
|
+
const srcDir = path.join(projectRoot, "src");
|
|
842
|
+
|
|
843
|
+
// Scan the codebase
|
|
844
|
+
const { elements, images, themeFiles, colorSources, filesScanned } = scanDirectory(srcDir, projectRoot);
|
|
845
|
+
|
|
846
|
+
// Analyze color architecture
|
|
847
|
+
const colorArchitecture = analyzeColorArchitecture(colorSources);
|
|
848
|
+
|
|
849
|
+
// Helper to create category summary
|
|
850
|
+
const createCategorySummary = (category: ElementCategory): CategorySummary => {
|
|
851
|
+
const categoryElements = elements.filter(el => el.category === category);
|
|
852
|
+
return {
|
|
853
|
+
total: categoryElements.length,
|
|
854
|
+
withId: categoryElements.filter(el => el.hasId).length,
|
|
855
|
+
missingId: categoryElements.filter(el => !el.hasId).length,
|
|
856
|
+
};
|
|
857
|
+
};
|
|
858
|
+
|
|
859
|
+
// Count brand logos (backwards compatibility)
|
|
860
|
+
const brandLogosDetected = images.filter(img => {
|
|
861
|
+
const src = (img.srcValue || "").toLowerCase();
|
|
862
|
+
return src.includes("logo") &&
|
|
863
|
+
(src.includes("sonance") || src.includes("iport") || src.includes("blaze"));
|
|
864
|
+
}).length;
|
|
865
|
+
|
|
866
|
+
const result: AnalysisResult = {
|
|
867
|
+
timestamp: new Date().toISOString(),
|
|
868
|
+
scanDuration: Date.now() - startTime,
|
|
869
|
+
filesScanned,
|
|
870
|
+
elements,
|
|
871
|
+
images, // Backwards compatibility
|
|
872
|
+
themeFiles,
|
|
873
|
+
colorArchitecture,
|
|
874
|
+
summary: {
|
|
875
|
+
totalElements: elements.length,
|
|
876
|
+
elementsWithId: elements.filter(el => el.hasId).length,
|
|
877
|
+
elementsMissingId: elements.filter(el => !el.hasId).length,
|
|
878
|
+
byCategory: {
|
|
879
|
+
image: createCategorySummary("image"),
|
|
880
|
+
text: createCategorySummary("text"),
|
|
881
|
+
interactive: createCategorySummary("interactive"),
|
|
882
|
+
input: createCategorySummary("input"),
|
|
883
|
+
definition: createCategorySummary("definition"),
|
|
884
|
+
},
|
|
885
|
+
// Legacy fields
|
|
886
|
+
totalImages: images.length,
|
|
887
|
+
imagesWithId: images.filter(img => img.hasId).length,
|
|
888
|
+
imagesMissingId: images.filter(img => !img.hasId).length,
|
|
889
|
+
brandLogosDetected,
|
|
890
|
+
},
|
|
891
|
+
};
|
|
892
|
+
|
|
893
|
+
return NextResponse.json(result);
|
|
894
|
+
} catch (error) {
|
|
895
|
+
console.error("Error analyzing project:", error);
|
|
896
|
+
return NextResponse.json(
|
|
897
|
+
{ error: "Failed to analyze project", details: String(error) },
|
|
898
|
+
{ status: 500 }
|
|
899
|
+
);
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
// POST handler for bulk operations
|
|
904
|
+
export async function POST(request: Request) {
|
|
905
|
+
// Security: Only allow in development
|
|
906
|
+
if (process.env.NODE_ENV !== "development") {
|
|
907
|
+
return NextResponse.json(
|
|
908
|
+
{ error: "This endpoint is only available in development mode." },
|
|
909
|
+
{ status: 403 }
|
|
910
|
+
);
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
try {
|
|
914
|
+
const body = await request.json();
|
|
915
|
+
const { action, elementIds, category } = body;
|
|
916
|
+
|
|
917
|
+
// Support legacy "imageIds" field
|
|
918
|
+
const targetIds: string[] | undefined = elementIds || body.imageIds;
|
|
919
|
+
|
|
920
|
+
if (action === "auto-tag-all") {
|
|
921
|
+
// Bulk inject IDs into elements missing them
|
|
922
|
+
const projectRoot = process.cwd();
|
|
923
|
+
const srcDir = path.join(projectRoot, "src");
|
|
924
|
+
|
|
925
|
+
// Re-scan to get fresh data
|
|
926
|
+
const { elements } = scanDirectory(srcDir, projectRoot);
|
|
927
|
+
|
|
928
|
+
// Filter to elements that need IDs
|
|
929
|
+
let targetElements = elements.filter(el => !el.hasId);
|
|
930
|
+
|
|
931
|
+
// Filter by specific IDs if provided
|
|
932
|
+
if (targetIds && targetIds.length > 0) {
|
|
933
|
+
targetElements = targetElements.filter(el => targetIds.includes(el.id));
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
// Filter by category if provided (e.g., "image", "text", "component", "input")
|
|
937
|
+
if (category) {
|
|
938
|
+
targetElements = targetElements.filter(el => el.category === category);
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
// Group elements by file and sort by character INDEX DESCENDING within each file
|
|
942
|
+
// This is critical: processing from right-to-left (end-to-start) prevents index shifts
|
|
943
|
+
// from affecting subsequent elements in the same file
|
|
944
|
+
// Using character index instead of line number handles multiple elements on the same line
|
|
945
|
+
const elementsByFile = new Map<string, typeof targetElements>();
|
|
946
|
+
for (const el of targetElements) {
|
|
947
|
+
const existing = elementsByFile.get(el.filePath) || [];
|
|
948
|
+
existing.push(el);
|
|
949
|
+
elementsByFile.set(el.filePath, existing);
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
// Sort each file's elements by character index descending (process right-to-left)
|
|
953
|
+
for (const [, fileElements] of elementsByFile) {
|
|
954
|
+
fileElements.sort((a, b) => b.index - a.index);
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
// Flatten back to array, processing by file, right-to-left within each file
|
|
958
|
+
targetElements = Array.from(elementsByFile.values()).flat();
|
|
959
|
+
|
|
960
|
+
const results: { id: string; success: boolean; error?: string }[] = [];
|
|
961
|
+
|
|
962
|
+
for (const element of targetElements) {
|
|
963
|
+
if (!element.suggestedId) {
|
|
964
|
+
results.push({ id: element.id, success: false, error: "No suggested ID" });
|
|
965
|
+
continue;
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
try {
|
|
969
|
+
const fullPath = path.join(projectRoot, element.filePath);
|
|
970
|
+
let content = fs.readFileSync(fullPath, "utf-8");
|
|
971
|
+
|
|
972
|
+
// Special handling for component definitions - use data-sonance-name instead of id
|
|
973
|
+
if (element.category === "definition") {
|
|
974
|
+
const componentName = element.context.parentComponent || element.suggestedId;
|
|
975
|
+
const tagResult = tagComponentDefinition(fullPath, content, componentName);
|
|
976
|
+
|
|
977
|
+
if (tagResult.success) {
|
|
978
|
+
fs.writeFileSync(fullPath, tagResult.content, "utf-8");
|
|
979
|
+
results.push({ id: element.id, success: true });
|
|
980
|
+
} else {
|
|
981
|
+
results.push({ id: element.id, success: false, error: tagResult.error });
|
|
982
|
+
}
|
|
983
|
+
continue;
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
const tagName = element.elementType; // e.g., "Image", "img", "h1", "Button", etc.
|
|
987
|
+
|
|
988
|
+
// Use the stored character index directly for precise targeting
|
|
989
|
+
// This avoids ambiguity when multiple elements of same type are on one line
|
|
990
|
+
const absoluteTagStart = element.index;
|
|
991
|
+
|
|
992
|
+
// Verify the tag is at the expected position
|
|
993
|
+
const expectedTag = `<${tagName}`;
|
|
994
|
+
if (!content.substring(absoluteTagStart, absoluteTagStart + expectedTag.length + 1).startsWith(expectedTag)) {
|
|
995
|
+
results.push({ id: element.id, success: false, error: `Tag mismatch at index ${absoluteTagStart}` });
|
|
996
|
+
continue;
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
// Find the end of the opening tag (the closing > or />)
|
|
1000
|
+
const afterTagName = absoluteTagStart + tagName.length + 1; // +1 for <
|
|
1001
|
+
let tagEndPos = -1;
|
|
1002
|
+
let inString = false;
|
|
1003
|
+
let stringChar = '';
|
|
1004
|
+
let inJsxExpr = 0;
|
|
1005
|
+
|
|
1006
|
+
for (let i = afterTagName; i < content.length; i++) {
|
|
1007
|
+
const char = content[i];
|
|
1008
|
+
const prevChar = i > 0 ? content[i - 1] : '';
|
|
1009
|
+
|
|
1010
|
+
// Track string boundaries
|
|
1011
|
+
if ((char === '"' || char === "'" || char === '`') && prevChar !== '\\') {
|
|
1012
|
+
if (!inString) {
|
|
1013
|
+
inString = true;
|
|
1014
|
+
stringChar = char;
|
|
1015
|
+
} else if (char === stringChar) {
|
|
1016
|
+
inString = false;
|
|
1017
|
+
stringChar = '';
|
|
1018
|
+
}
|
|
1019
|
+
continue;
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
if (inString) continue;
|
|
1023
|
+
|
|
1024
|
+
// Track JSX expression boundaries
|
|
1025
|
+
if (char === '{') {
|
|
1026
|
+
inJsxExpr++;
|
|
1027
|
+
continue;
|
|
1028
|
+
}
|
|
1029
|
+
if (char === '}') {
|
|
1030
|
+
inJsxExpr--;
|
|
1031
|
+
continue;
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
if (inJsxExpr > 0) continue;
|
|
1035
|
+
|
|
1036
|
+
// Check for self-closing or regular closing
|
|
1037
|
+
if (char === '/' && content[i + 1] === '>') {
|
|
1038
|
+
tagEndPos = i;
|
|
1039
|
+
break;
|
|
1040
|
+
}
|
|
1041
|
+
if (char === '>') {
|
|
1042
|
+
tagEndPos = i;
|
|
1043
|
+
break;
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
if (tagEndPos === -1) {
|
|
1048
|
+
results.push({ id: element.id, success: false, error: "Could not find end of opening tag" });
|
|
1049
|
+
continue;
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
// Check if this tag already has an id (shouldn't happen, but safety check)
|
|
1053
|
+
const tagContent = content.substring(absoluteTagStart, tagEndPos);
|
|
1054
|
+
if (/\bid\s*=/.test(tagContent)) {
|
|
1055
|
+
results.push({ id: element.id, success: false, error: "Tag already has id attribute" });
|
|
1056
|
+
continue;
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
// Inject the id after the tag name
|
|
1060
|
+
const insertPos = absoluteTagStart + tagName.length + 1; // Right after <TagName
|
|
1061
|
+
|
|
1062
|
+
// Determine what comes after the tag name
|
|
1063
|
+
const charAfterTagName = content[insertPos];
|
|
1064
|
+
let injection: string;
|
|
1065
|
+
|
|
1066
|
+
if (charAfterTagName === '\n' || charAfterTagName === '\r') {
|
|
1067
|
+
// Multi-line format: <Button\n variant=...
|
|
1068
|
+
const nextLineStart = content.indexOf('\n', insertPos) + 1;
|
|
1069
|
+
const nextLineMatch = content.substring(nextLineStart).match(/^(\s*)/);
|
|
1070
|
+
const indent = nextLineMatch ? nextLineMatch[1] : ' ';
|
|
1071
|
+
injection = `\n${indent}id="${element.suggestedId}"`;
|
|
1072
|
+
} else if (charAfterTagName === ' ' || charAfterTagName === '\t') {
|
|
1073
|
+
// Single-line format with space: <h1 className=...
|
|
1074
|
+
injection = ` id="${element.suggestedId}"`;
|
|
1075
|
+
} else if (charAfterTagName === '>' || charAfterTagName === '/') {
|
|
1076
|
+
// Self-closing without attributes: <Input/> or <h1>
|
|
1077
|
+
injection = ` id="${element.suggestedId}"`;
|
|
1078
|
+
} else {
|
|
1079
|
+
// Unknown format, try with space
|
|
1080
|
+
injection = ` id="${element.suggestedId}"`;
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
// Perform the injection
|
|
1084
|
+
const newContent = content.substring(0, insertPos) + injection + content.substring(insertPos);
|
|
1085
|
+
|
|
1086
|
+
fs.writeFileSync(fullPath, newContent, "utf-8");
|
|
1087
|
+
results.push({ id: element.id, success: true });
|
|
1088
|
+
} catch (error) {
|
|
1089
|
+
results.push({ id: element.id, success: false, error: String(error) });
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
const successCount = results.filter(r => r.success).length;
|
|
1094
|
+
const failCount = results.filter(r => !r.success).length;
|
|
1095
|
+
const categoryLabel = category || "elements";
|
|
1096
|
+
|
|
1097
|
+
return NextResponse.json({
|
|
1098
|
+
success: true,
|
|
1099
|
+
message: `Tagged ${successCount} ${categoryLabel}. ${failCount} failed.`,
|
|
1100
|
+
results,
|
|
1101
|
+
});
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
return NextResponse.json(
|
|
1105
|
+
{ error: "Unknown action" },
|
|
1106
|
+
{ status: 400 }
|
|
1107
|
+
);
|
|
1108
|
+
} catch (error) {
|
|
1109
|
+
console.error("Error in bulk operation:", error);
|
|
1110
|
+
return NextResponse.json(
|
|
1111
|
+
{ error: "Failed to perform bulk operation", details: String(error) },
|
|
1112
|
+
{ status: 500 }
|
|
1113
|
+
);
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
|