sonance-brand-mcp 1.3.1 → 1.3.3

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.
Files changed (136) hide show
  1. package/dist/assets/api/sonance-analyze/route.ts +1116 -0
  2. package/dist/assets/api/sonance-assets/route.ts +113 -0
  3. package/dist/assets/api/sonance-components/route.ts +41 -0
  4. package/dist/assets/api/sonance-inject-id/route.ts +363 -0
  5. package/dist/assets/api/sonance-save-logo/route.ts +426 -0
  6. package/dist/assets/api/sonance-theme/route.ts +106 -0
  7. package/dist/assets/brand-system.ts +1265 -0
  8. package/dist/assets/components/accordion.stories.tsx +26 -26
  9. package/dist/assets/components/accordion.tsx +3 -3
  10. package/dist/assets/components/alert-dialog.stories.tsx +7 -7
  11. package/dist/assets/components/alert-dialog.tsx +2 -1
  12. package/dist/assets/components/alert.stories.tsx +3 -3
  13. package/dist/assets/components/alert.tsx +4 -3
  14. package/dist/assets/components/aspect-ratio.stories.tsx +4 -1
  15. package/dist/assets/components/autocomplete.stories.tsx +9 -9
  16. package/dist/assets/components/autocomplete.tsx +3 -3
  17. package/dist/assets/components/avatar.stories.tsx +5 -5
  18. package/dist/assets/components/avatar.tsx +4 -4
  19. package/dist/assets/components/badge.stories.tsx +10 -10
  20. package/dist/assets/components/badge.tsx +3 -3
  21. package/dist/assets/components/breadcrumbs.stories.tsx +7 -7
  22. package/dist/assets/components/breadcrumbs.tsx +13 -8
  23. package/dist/assets/components/button.stories.tsx +74 -74
  24. package/dist/assets/components/button.tsx +2 -0
  25. package/dist/assets/components/calendar.stories.tsx +11 -11
  26. package/dist/assets/components/calendar.tsx +4 -4
  27. package/dist/assets/components/card.stories.tsx +22 -22
  28. package/dist/assets/components/card.tsx +7 -3
  29. package/dist/assets/components/carousel.stories.tsx +6 -6
  30. package/dist/assets/components/carousel.tsx +10 -8
  31. package/dist/assets/components/chart.tsx +5 -5
  32. package/dist/assets/components/checkbox-group.stories.tsx +6 -6
  33. package/dist/assets/components/checkbox-group.tsx +3 -3
  34. package/dist/assets/components/checkbox.stories.tsx +23 -20
  35. package/dist/assets/components/checkbox.tsx +13 -16
  36. package/dist/assets/components/code.stories.tsx +24 -24
  37. package/dist/assets/components/code.tsx +7 -14
  38. package/dist/assets/components/collapsible.stories.tsx +3 -3
  39. package/dist/assets/components/command.stories.tsx +14 -14
  40. package/dist/assets/components/command.tsx +4 -3
  41. package/dist/assets/components/context-menu.stories.tsx +1 -1
  42. package/dist/assets/components/context-menu.tsx +3 -7
  43. package/dist/assets/components/date-input.stories.tsx +9 -9
  44. package/dist/assets/components/date-input.tsx +2 -2
  45. package/dist/assets/components/date-picker.stories.tsx +9 -9
  46. package/dist/assets/components/date-picker.tsx +3 -3
  47. package/dist/assets/components/date-range-picker.stories.tsx +12 -12
  48. package/dist/assets/components/date-range-picker.tsx +3 -3
  49. package/dist/assets/components/dialog.stories.tsx +40 -40
  50. package/dist/assets/components/dialog.tsx +8 -12
  51. package/dist/assets/components/divider.stories.tsx +30 -30
  52. package/dist/assets/components/divider.tsx +4 -8
  53. package/dist/assets/components/drawer.stories.tsx +32 -31
  54. package/dist/assets/components/drawer.tsx +7 -6
  55. package/dist/assets/components/dropdown-menu.tsx +3 -7
  56. package/dist/assets/components/dropdown.stories.tsx +12 -12
  57. package/dist/assets/components/dropdown.tsx +5 -5
  58. package/dist/assets/components/form.stories.tsx +30 -29
  59. package/dist/assets/components/form.tsx +5 -5
  60. package/dist/assets/components/hover-card.stories.tsx +12 -10
  61. package/dist/assets/components/hover-card.tsx +1 -1
  62. package/dist/assets/components/image.stories.tsx +48 -25
  63. package/dist/assets/components/image.tsx +8 -5
  64. package/dist/assets/components/input-otp.stories.tsx +15 -15
  65. package/dist/assets/components/input-otp.tsx +5 -5
  66. package/dist/assets/components/input.stories.tsx +30 -25
  67. package/dist/assets/components/input.tsx +7 -4
  68. package/dist/assets/components/kbd.stories.tsx +34 -34
  69. package/dist/assets/components/kbd.tsx +5 -5
  70. package/dist/assets/components/link.stories.tsx +36 -36
  71. package/dist/assets/components/link.tsx +4 -0
  72. package/dist/assets/components/listbox.stories.tsx +5 -5
  73. package/dist/assets/components/listbox.tsx +4 -4
  74. package/dist/assets/components/menubar.tsx +3 -7
  75. package/dist/assets/components/navbar.stories.tsx +24 -24
  76. package/dist/assets/components/navbar.tsx +8 -14
  77. package/dist/assets/components/navigation-menu.stories.tsx +11 -9
  78. package/dist/assets/components/navigation-menu.tsx +1 -1
  79. package/dist/assets/components/number-input.stories.tsx +11 -11
  80. package/dist/assets/components/number-input.tsx +3 -3
  81. package/dist/assets/components/pagination.stories.tsx +13 -13
  82. package/dist/assets/components/pagination.tsx +6 -6
  83. package/dist/assets/components/popover.stories.tsx +35 -35
  84. package/dist/assets/components/popover.tsx +98 -15
  85. package/dist/assets/components/progress.stories.tsx +5 -5
  86. package/dist/assets/components/progress.tsx +5 -5
  87. package/dist/assets/components/radio-group.stories.tsx +7 -7
  88. package/dist/assets/components/radio-group.tsx +3 -3
  89. package/dist/assets/components/range-calendar.stories.tsx +18 -18
  90. package/dist/assets/components/range-calendar.tsx +3 -3
  91. package/dist/assets/components/resizable.stories.tsx +23 -23
  92. package/dist/assets/components/resizable.tsx +1 -1
  93. package/dist/assets/components/scroll-area.stories.tsx +15 -15
  94. package/dist/assets/components/scroll-area.tsx +1 -1
  95. package/dist/assets/components/scroll-shadow.stories.tsx +17 -17
  96. package/dist/assets/components/scroll-shadow.tsx +2 -2
  97. package/dist/assets/components/select.stories.tsx +20 -19
  98. package/dist/assets/components/select.tsx +10 -6
  99. package/dist/assets/components/separator.tsx +1 -1
  100. package/dist/assets/components/sheet.tsx +3 -7
  101. package/dist/assets/components/sidebar.stories.tsx +30 -30
  102. package/dist/assets/components/sidebar.tsx +24 -27
  103. package/dist/assets/components/skeleton.stories.tsx +3 -3
  104. package/dist/assets/components/skeleton.tsx +2 -2
  105. package/dist/assets/components/slider.stories.tsx +6 -6
  106. package/dist/assets/components/slider.tsx +3 -3
  107. package/dist/assets/components/spacer.stories.tsx +11 -11
  108. package/dist/assets/components/spacer.tsx +2 -2
  109. package/dist/assets/components/spinner.stories.tsx +8 -8
  110. package/dist/assets/components/spinner.tsx +5 -5
  111. package/dist/assets/components/switch.stories.tsx +24 -20
  112. package/dist/assets/components/switch.tsx +14 -6
  113. package/dist/assets/components/table.stories.tsx +7 -7
  114. package/dist/assets/components/table.tsx +8 -8
  115. package/dist/assets/components/tabs.stories.tsx +37 -37
  116. package/dist/assets/components/tabs.tsx +3 -3
  117. package/dist/assets/components/textarea.stories.tsx +13 -12
  118. package/dist/assets/components/textarea.tsx +3 -3
  119. package/dist/assets/components/theme-toggle.stories.tsx +31 -30
  120. package/dist/assets/components/theme-toggle.tsx +2 -2
  121. package/dist/assets/components/time-input.stories.tsx +16 -16
  122. package/dist/assets/components/time-input.tsx +2 -2
  123. package/dist/assets/components/toast.stories.tsx +8 -5
  124. package/dist/assets/components/toast.tsx +6 -6
  125. package/dist/assets/components/toggle-group.tsx +1 -1
  126. package/dist/assets/components/toggle.tsx +1 -1
  127. package/dist/assets/components/tooltip.stories.tsx +49 -27
  128. package/dist/assets/components/tooltip.tsx +1 -1
  129. package/dist/assets/components/user.stories.tsx +23 -23
  130. package/dist/assets/components/user.tsx +7 -4
  131. package/dist/assets/dev-tools/SonanceDevTools.tsx +4201 -0
  132. package/dist/assets/dev-tools/index.ts +10 -0
  133. package/dist/assets/globals.css +9 -0
  134. package/dist/assets/styles/brand-overrides.css +37 -0
  135. package/dist/index.js +1882 -7
  136. 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
+