@getcoherent/cli 0.6.13 → 0.6.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/dist/ai-classifier-EGXPFJLN.js +42 -0
- package/dist/ai-provider-HUQO64P3.js +13 -0
- package/dist/chunk-25JRF5MA.js +124 -0
- package/dist/{chunk-CLPILU3Z.js → chunk-5AHG4NNX.js} +10 -292
- package/dist/chunk-N6H73ROO.js +1244 -0
- package/dist/chunk-WRDWFCQJ.js +323 -0
- package/dist/component-extractor-VYJLT5NR.js +56 -0
- package/dist/design-constraints-EIP2XM7T.js +43 -0
- package/dist/index.js +857 -1914
- package/dist/{plan-generator-QUESV7GS.js → plan-generator-H55WEIY2.js} +2 -1
- package/dist/quality-validator-3K5BMJSR.js +15 -0
- package/dist/reuse-validator-HC4LZEKF.js +74 -0
- package/package.json +10 -10
|
@@ -0,0 +1,1244 @@
|
|
|
1
|
+
// src/utils/component-rules.ts
|
|
2
|
+
function extractJsxElementProps(code, openTagStart) {
|
|
3
|
+
let i = openTagStart;
|
|
4
|
+
if (code[i] !== "<") return null;
|
|
5
|
+
i++;
|
|
6
|
+
let braceDepth = 0;
|
|
7
|
+
let inSingleQuote = false;
|
|
8
|
+
let inDoubleQuote = false;
|
|
9
|
+
let inTemplateLiteral = false;
|
|
10
|
+
let escaped = false;
|
|
11
|
+
while (i < code.length) {
|
|
12
|
+
const ch = code[i];
|
|
13
|
+
if (escaped) {
|
|
14
|
+
escaped = false;
|
|
15
|
+
i++;
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
if (ch === "\\" && (inSingleQuote || inDoubleQuote || inTemplateLiteral)) {
|
|
19
|
+
escaped = true;
|
|
20
|
+
i++;
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
if (!inDoubleQuote && !inTemplateLiteral && ch === "'" && braceDepth > 0) {
|
|
24
|
+
inSingleQuote = !inSingleQuote;
|
|
25
|
+
} else if (!inSingleQuote && !inTemplateLiteral && ch === '"') {
|
|
26
|
+
inDoubleQuote = !inDoubleQuote;
|
|
27
|
+
} else if (!inSingleQuote && !inDoubleQuote && ch === "`") {
|
|
28
|
+
inTemplateLiteral = !inTemplateLiteral;
|
|
29
|
+
}
|
|
30
|
+
if (!inSingleQuote && !inDoubleQuote && !inTemplateLiteral) {
|
|
31
|
+
if (ch === "{") braceDepth++;
|
|
32
|
+
else if (ch === "}") braceDepth--;
|
|
33
|
+
else if (ch === ">" && braceDepth === 0) {
|
|
34
|
+
return code.slice(openTagStart, i + 1);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
i++;
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
var NAV_STYLE_SIGNAL = /text-muted-foreground/;
|
|
42
|
+
var buttonMissingGhostVariant = {
|
|
43
|
+
id: "button-missing-ghost-variant",
|
|
44
|
+
component: "Button",
|
|
45
|
+
detect(code) {
|
|
46
|
+
const issues = [];
|
|
47
|
+
const buttonRe = /<Button\s/g;
|
|
48
|
+
let match;
|
|
49
|
+
while ((match = buttonRe.exec(code)) !== null) {
|
|
50
|
+
const props = extractJsxElementProps(code, match.index);
|
|
51
|
+
if (!props) continue;
|
|
52
|
+
if (/\bvariant\s*=/.test(props)) continue;
|
|
53
|
+
if (!NAV_STYLE_SIGNAL.test(props)) continue;
|
|
54
|
+
const line = code.slice(0, match.index).split("\n").length;
|
|
55
|
+
issues.push({
|
|
56
|
+
line,
|
|
57
|
+
type: "BUTTON_MISSING_VARIANT",
|
|
58
|
+
message: '<Button> with navigation-style classes (text-muted-foreground) but no variant \u2014 add variant="ghost"',
|
|
59
|
+
severity: "error"
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
return issues;
|
|
63
|
+
},
|
|
64
|
+
fix(code) {
|
|
65
|
+
let result = code;
|
|
66
|
+
let applied = false;
|
|
67
|
+
const buttonRe = /<Button\s/g;
|
|
68
|
+
let match;
|
|
69
|
+
let offset = 0;
|
|
70
|
+
while ((match = buttonRe.exec(code)) !== null) {
|
|
71
|
+
const adjustedIndex = match.index + offset;
|
|
72
|
+
const props = extractJsxElementProps(result, adjustedIndex);
|
|
73
|
+
if (!props) continue;
|
|
74
|
+
if (/\bvariant\s*=/.test(props)) continue;
|
|
75
|
+
if (!NAV_STYLE_SIGNAL.test(props)) continue;
|
|
76
|
+
const insertPos = adjustedIndex + "<Button".length;
|
|
77
|
+
const insertion = "\n" + getIndent(result, adjustedIndex) + ' variant="ghost"';
|
|
78
|
+
result = result.slice(0, insertPos) + insertion + result.slice(insertPos);
|
|
79
|
+
offset += insertion.length;
|
|
80
|
+
applied = true;
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
code: result,
|
|
84
|
+
applied,
|
|
85
|
+
description: 'added variant="ghost" to Button with nav-style classes'
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
function getIndent(code, pos) {
|
|
90
|
+
const lineStart = code.lastIndexOf("\n", pos);
|
|
91
|
+
const lineContent = code.slice(lineStart + 1, pos);
|
|
92
|
+
const indentMatch = lineContent.match(/^(\s*)/);
|
|
93
|
+
return indentMatch ? indentMatch[1] : "";
|
|
94
|
+
}
|
|
95
|
+
var buttonWFullGhostJustify = {
|
|
96
|
+
id: "button-w-full-ghost-justify",
|
|
97
|
+
component: "Button",
|
|
98
|
+
detect(code) {
|
|
99
|
+
const issues = [];
|
|
100
|
+
const buttonRe = /<Button\s/g;
|
|
101
|
+
let match;
|
|
102
|
+
while ((match = buttonRe.exec(code)) !== null) {
|
|
103
|
+
const props = extractJsxElementProps(code, match.index);
|
|
104
|
+
if (!props) continue;
|
|
105
|
+
const hasGhost = /variant\s*=\s*["']ghost["']/.test(props);
|
|
106
|
+
const hasWFull = /w-full/.test(props);
|
|
107
|
+
const hasJustifyStart = /justify-start/.test(props);
|
|
108
|
+
if (hasGhost && hasWFull && !hasJustifyStart) {
|
|
109
|
+
issues.push({
|
|
110
|
+
line: 0,
|
|
111
|
+
type: "button-w-full-ghost-justify",
|
|
112
|
+
severity: "warning",
|
|
113
|
+
message: 'Button with w-full and variant="ghost" should include justify-start for proper text alignment'
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return issues;
|
|
118
|
+
},
|
|
119
|
+
fix(code) {
|
|
120
|
+
let applied = false;
|
|
121
|
+
const result = code.replace(
|
|
122
|
+
/(<Button\s[^>]*variant\s*=\s*["']ghost["'][^>]*className\s*=\s*["'][^"']*)(w-full)([^"']*["'])/g,
|
|
123
|
+
(full, before, wFull, after) => {
|
|
124
|
+
if (full.includes("justify-start")) return full;
|
|
125
|
+
applied = true;
|
|
126
|
+
return `${before}${wFull} justify-start${after}`;
|
|
127
|
+
}
|
|
128
|
+
);
|
|
129
|
+
return {
|
|
130
|
+
code: result,
|
|
131
|
+
applied,
|
|
132
|
+
description: "Added justify-start to ghost Button with w-full"
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
var rules = [buttonMissingGhostVariant, buttonWFullGhostJustify];
|
|
137
|
+
function detectComponentIssues(code) {
|
|
138
|
+
const issues = [];
|
|
139
|
+
for (const rule of rules) {
|
|
140
|
+
issues.push(...rule.detect(code));
|
|
141
|
+
}
|
|
142
|
+
return issues;
|
|
143
|
+
}
|
|
144
|
+
function applyComponentRules(code) {
|
|
145
|
+
const fixes = [];
|
|
146
|
+
let result = code;
|
|
147
|
+
for (const rule of rules) {
|
|
148
|
+
const { code: fixed, applied, description } = rule.fix(result);
|
|
149
|
+
if (applied) {
|
|
150
|
+
result = fixed;
|
|
151
|
+
fixes.push(description);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return { code: result, fixes };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// src/utils/quality-validator.ts
|
|
158
|
+
var RAW_COLOR_RE = /(?:(?:hover|focus|active|group-hover|focus-visible|focus-within):)?(?:bg|text|border|ring|outline|from|to|via)-(gray|blue|red|green|yellow|purple|pink|indigo|orange|slate|zinc|stone|neutral|emerald|teal|cyan|sky|violet|fuchsia|rose|amber|lime)-\d+/g;
|
|
159
|
+
var HEX_IN_CLASS_RE = /className="[^"]*#[0-9a-fA-F]{3,8}[^"]*"/g;
|
|
160
|
+
var TEXT_BASE_RE = /\btext-base\b/g;
|
|
161
|
+
var HEAVY_SHADOW_RE = /\bshadow-(md|lg|xl|2xl)\b/g;
|
|
162
|
+
var SM_BREAKPOINT_RE = /\bsm:/g;
|
|
163
|
+
var XL_BREAKPOINT_RE = /\bxl:/g;
|
|
164
|
+
var XXL_BREAKPOINT_RE = /\b2xl:/g;
|
|
165
|
+
var LARGE_CARD_TITLE_RE = /CardTitle[^>]*className="[^"]*text-(lg|xl|2xl)/g;
|
|
166
|
+
var RAW_BUTTON_RE = /<button\b/g;
|
|
167
|
+
var RAW_INPUT_RE = /<input\b/g;
|
|
168
|
+
var RAW_SELECT_RE = /<select\b/g;
|
|
169
|
+
var NATIVE_CHECKBOX_RE = /<input[^>]*type\s*=\s*["']checkbox["']/g;
|
|
170
|
+
var NATIVE_TABLE_RE = /<table\b/g;
|
|
171
|
+
var PLACEHOLDER_PATTERNS = [
|
|
172
|
+
/>\s*Lorem ipsum\b/i,
|
|
173
|
+
/>\s*Card content\s*</i,
|
|
174
|
+
/>\s*Your (?:text|content) here\s*</i,
|
|
175
|
+
/>\s*Description\s*</,
|
|
176
|
+
/>\s*Title\s*</,
|
|
177
|
+
/placeholder\s*text/i
|
|
178
|
+
];
|
|
179
|
+
var GENERIC_BUTTON_LABELS = />\s*(Submit|OK|Click here|Press here|Go)\s*</i;
|
|
180
|
+
var IMG_WITHOUT_ALT_RE = /<img\b(?![^>]*\balt\s*=)[^>]*>/g;
|
|
181
|
+
var INPUT_TAG_RE = /<(?:Input|input)\b[^>]*>/g;
|
|
182
|
+
var LABEL_FOR_RE = /<Label\b[^>]*htmlFor\s*=/;
|
|
183
|
+
function isInsideCommentOrString(line, matchIndex) {
|
|
184
|
+
const commentIdx = line.indexOf("//");
|
|
185
|
+
if (commentIdx !== -1 && commentIdx < matchIndex) return true;
|
|
186
|
+
let inSingle = false;
|
|
187
|
+
let inDouble = false;
|
|
188
|
+
let inTemplate = false;
|
|
189
|
+
for (let i = 0; i < matchIndex; i++) {
|
|
190
|
+
const ch = line[i];
|
|
191
|
+
const prev = i > 0 ? line[i - 1] : "";
|
|
192
|
+
if (prev === "\\") continue;
|
|
193
|
+
if (ch === "'" && !inDouble && !inTemplate) inSingle = !inSingle;
|
|
194
|
+
if (ch === '"' && !inSingle && !inTemplate) inDouble = !inDouble;
|
|
195
|
+
if (ch === "`" && !inSingle && !inDouble) inTemplate = !inTemplate;
|
|
196
|
+
}
|
|
197
|
+
return inSingle || inDouble || inTemplate;
|
|
198
|
+
}
|
|
199
|
+
function checkLines(code, pattern, type, message, severity, skipCommentsAndStrings = false) {
|
|
200
|
+
const issues = [];
|
|
201
|
+
const lines = code.split("\n");
|
|
202
|
+
let inBlockComment = false;
|
|
203
|
+
for (let i = 0; i < lines.length; i++) {
|
|
204
|
+
const line = lines[i];
|
|
205
|
+
if (skipCommentsAndStrings) {
|
|
206
|
+
if (inBlockComment) {
|
|
207
|
+
const endIdx = line.indexOf("*/");
|
|
208
|
+
if (endIdx !== -1) {
|
|
209
|
+
inBlockComment = false;
|
|
210
|
+
}
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
const blockStart = line.indexOf("/*");
|
|
214
|
+
if (blockStart !== -1 && !line.includes("*/")) {
|
|
215
|
+
inBlockComment = true;
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
let m;
|
|
219
|
+
pattern.lastIndex = 0;
|
|
220
|
+
while ((m = pattern.exec(line)) !== null) {
|
|
221
|
+
if (!isInsideCommentOrString(line, m.index)) {
|
|
222
|
+
issues.push({ line: i + 1, type, message, severity });
|
|
223
|
+
break;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
} else {
|
|
227
|
+
pattern.lastIndex = 0;
|
|
228
|
+
if (pattern.test(line)) {
|
|
229
|
+
issues.push({ line: i + 1, type, message, severity });
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return issues;
|
|
234
|
+
}
|
|
235
|
+
function validatePageQuality(code, validRoutes, pageType) {
|
|
236
|
+
const issues = [];
|
|
237
|
+
const allLines = code.split("\n");
|
|
238
|
+
const isTerminalContext = (lineNum) => {
|
|
239
|
+
const start = Math.max(0, lineNum - 20);
|
|
240
|
+
const nearby = allLines.slice(start, lineNum).join(" ");
|
|
241
|
+
if (/font-mono/.test(allLines[lineNum - 1] || "")) return true;
|
|
242
|
+
if (/bg-zinc-950|bg-zinc-900/.test(nearby) && /font-mono/.test(nearby)) return true;
|
|
243
|
+
return false;
|
|
244
|
+
};
|
|
245
|
+
issues.push(
|
|
246
|
+
...checkLines(
|
|
247
|
+
code,
|
|
248
|
+
RAW_COLOR_RE,
|
|
249
|
+
"RAW_COLOR",
|
|
250
|
+
"Raw Tailwind color detected \u2014 use semantic tokens (bg-primary, text-muted-foreground, etc.)",
|
|
251
|
+
"error"
|
|
252
|
+
).filter((issue) => !isTerminalContext(issue.line))
|
|
253
|
+
);
|
|
254
|
+
issues.push(
|
|
255
|
+
...checkLines(
|
|
256
|
+
code,
|
|
257
|
+
HEX_IN_CLASS_RE,
|
|
258
|
+
"HEX_IN_CLASS",
|
|
259
|
+
"Hex color in className \u2014 use CSS variables via semantic tokens",
|
|
260
|
+
"error"
|
|
261
|
+
)
|
|
262
|
+
);
|
|
263
|
+
issues.push(
|
|
264
|
+
...checkLines(code, TEXT_BASE_RE, "TEXT_BASE", "text-base detected \u2014 use text-sm as base font size", "warning")
|
|
265
|
+
);
|
|
266
|
+
issues.push(
|
|
267
|
+
...checkLines(code, HEAVY_SHADOW_RE, "HEAVY_SHADOW", "Heavy shadow detected \u2014 use shadow-sm or none", "warning")
|
|
268
|
+
);
|
|
269
|
+
issues.push(
|
|
270
|
+
...checkLines(
|
|
271
|
+
code,
|
|
272
|
+
SM_BREAKPOINT_RE,
|
|
273
|
+
"SM_BREAKPOINT",
|
|
274
|
+
"sm: breakpoint \u2014 consider if md:/lg: is sufficient",
|
|
275
|
+
"info"
|
|
276
|
+
)
|
|
277
|
+
);
|
|
278
|
+
issues.push(
|
|
279
|
+
...checkLines(
|
|
280
|
+
code,
|
|
281
|
+
XL_BREAKPOINT_RE,
|
|
282
|
+
"XL_BREAKPOINT",
|
|
283
|
+
"xl: breakpoint \u2014 consider if md:/lg: is sufficient",
|
|
284
|
+
"info"
|
|
285
|
+
)
|
|
286
|
+
);
|
|
287
|
+
issues.push(
|
|
288
|
+
...checkLines(
|
|
289
|
+
code,
|
|
290
|
+
XXL_BREAKPOINT_RE,
|
|
291
|
+
"XXL_BREAKPOINT",
|
|
292
|
+
"2xl: breakpoint \u2014 rarely needed, consider xl: instead",
|
|
293
|
+
"warning"
|
|
294
|
+
)
|
|
295
|
+
);
|
|
296
|
+
issues.push(
|
|
297
|
+
...checkLines(
|
|
298
|
+
code,
|
|
299
|
+
LARGE_CARD_TITLE_RE,
|
|
300
|
+
"LARGE_CARD_TITLE",
|
|
301
|
+
"Large text on CardTitle \u2014 use text-sm font-medium",
|
|
302
|
+
"warning"
|
|
303
|
+
)
|
|
304
|
+
);
|
|
305
|
+
const codeLines = code.split("\n");
|
|
306
|
+
issues.push(
|
|
307
|
+
...checkLines(
|
|
308
|
+
code,
|
|
309
|
+
RAW_BUTTON_RE,
|
|
310
|
+
"NATIVE_BUTTON",
|
|
311
|
+
"Native <button> \u2014 use Button from @/components/ui/button",
|
|
312
|
+
"error",
|
|
313
|
+
true
|
|
314
|
+
).filter((issue) => {
|
|
315
|
+
const nearby = codeLines.slice(Math.max(0, issue.line - 1), issue.line + 5).join(" ");
|
|
316
|
+
if (nearby.includes("aria-label")) return false;
|
|
317
|
+
if (/onClick=\{.*copy/i.test(nearby)) return false;
|
|
318
|
+
return true;
|
|
319
|
+
})
|
|
320
|
+
);
|
|
321
|
+
issues.push(
|
|
322
|
+
...checkLines(
|
|
323
|
+
code,
|
|
324
|
+
RAW_SELECT_RE,
|
|
325
|
+
"NATIVE_SELECT",
|
|
326
|
+
"Native <select> \u2014 use Select from @/components/ui/select",
|
|
327
|
+
"error",
|
|
328
|
+
true
|
|
329
|
+
)
|
|
330
|
+
);
|
|
331
|
+
issues.push(
|
|
332
|
+
...checkLines(
|
|
333
|
+
code,
|
|
334
|
+
NATIVE_CHECKBOX_RE,
|
|
335
|
+
"NATIVE_CHECKBOX",
|
|
336
|
+
'Native <input type="checkbox"> \u2014 use Switch or Checkbox from @/components/ui/switch or @/components/ui/checkbox',
|
|
337
|
+
"error",
|
|
338
|
+
true
|
|
339
|
+
)
|
|
340
|
+
);
|
|
341
|
+
issues.push(
|
|
342
|
+
...checkLines(
|
|
343
|
+
code,
|
|
344
|
+
NATIVE_TABLE_RE,
|
|
345
|
+
"NATIVE_TABLE",
|
|
346
|
+
"Native <table> \u2014 use Table, TableHeader, TableBody, etc. from @/components/ui/table",
|
|
347
|
+
"warning",
|
|
348
|
+
true
|
|
349
|
+
)
|
|
350
|
+
);
|
|
351
|
+
const hasInputImport = /import\s.*Input.*from\s+['"]@\/components\/ui\//.test(code);
|
|
352
|
+
if (!hasInputImport) {
|
|
353
|
+
issues.push(
|
|
354
|
+
...checkLines(
|
|
355
|
+
code,
|
|
356
|
+
RAW_INPUT_RE,
|
|
357
|
+
"RAW_INPUT",
|
|
358
|
+
"Raw <input> element \u2014 import and use Input from @/components/ui/input",
|
|
359
|
+
"warning",
|
|
360
|
+
true
|
|
361
|
+
)
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
for (const pattern of PLACEHOLDER_PATTERNS) {
|
|
365
|
+
const lines = code.split("\n");
|
|
366
|
+
for (let i = 0; i < lines.length; i++) {
|
|
367
|
+
if (pattern.test(lines[i])) {
|
|
368
|
+
issues.push({
|
|
369
|
+
line: i + 1,
|
|
370
|
+
type: "PLACEHOLDER",
|
|
371
|
+
message: "Placeholder content detected \u2014 use real contextual content",
|
|
372
|
+
severity: "error"
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
const hasGrid = /\bgrid\b/.test(code);
|
|
378
|
+
const hasResponsive = /\bmd:|lg:/.test(code);
|
|
379
|
+
if (hasGrid && !hasResponsive) {
|
|
380
|
+
issues.push({
|
|
381
|
+
line: 0,
|
|
382
|
+
type: "NO_RESPONSIVE",
|
|
383
|
+
message: "Grid layout without responsive breakpoints (md: or lg:)",
|
|
384
|
+
severity: "warning"
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
issues.push(
|
|
388
|
+
...checkLines(
|
|
389
|
+
code,
|
|
390
|
+
IMG_WITHOUT_ALT_RE,
|
|
391
|
+
"MISSING_ALT",
|
|
392
|
+
'<img> without alt attribute \u2014 add descriptive alt or alt="" for decorative images',
|
|
393
|
+
"error"
|
|
394
|
+
)
|
|
395
|
+
);
|
|
396
|
+
issues.push(
|
|
397
|
+
...checkLines(
|
|
398
|
+
code,
|
|
399
|
+
GENERIC_BUTTON_LABELS,
|
|
400
|
+
"GENERIC_BUTTON_TEXT",
|
|
401
|
+
'Generic button text \u2014 use specific verb ("Save changes", "Delete account")',
|
|
402
|
+
"warning"
|
|
403
|
+
)
|
|
404
|
+
);
|
|
405
|
+
if (pageType !== "auth") {
|
|
406
|
+
const h1Matches = code.match(/<h1[\s>]/g);
|
|
407
|
+
if (!h1Matches || h1Matches.length === 0) {
|
|
408
|
+
issues.push({
|
|
409
|
+
line: 0,
|
|
410
|
+
type: "NO_H1",
|
|
411
|
+
message: "Page has no <h1> \u2014 every page should have exactly one h1 heading",
|
|
412
|
+
severity: "warning"
|
|
413
|
+
});
|
|
414
|
+
} else if (h1Matches.length > 1) {
|
|
415
|
+
issues.push({
|
|
416
|
+
line: 0,
|
|
417
|
+
type: "MULTIPLE_H1",
|
|
418
|
+
message: `Page has ${h1Matches.length} <h1> elements \u2014 use exactly one per page`,
|
|
419
|
+
severity: "warning"
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
const headingLevels = [...code.matchAll(/<h([1-6])[\s>]/g)].map((m) => parseInt(m[1]));
|
|
424
|
+
const hasCardContext = /\bCard\b|\bCardTitle\b|\bCardHeader\b/.test(code);
|
|
425
|
+
for (let i = 1; i < headingLevels.length; i++) {
|
|
426
|
+
if (headingLevels[i] > headingLevels[i - 1] + 1) {
|
|
427
|
+
issues.push({
|
|
428
|
+
line: 0,
|
|
429
|
+
type: "SKIPPED_HEADING",
|
|
430
|
+
message: `Heading level skipped: h${headingLevels[i - 1]} \u2192 h${headingLevels[i]} \u2014 don't skip levels`,
|
|
431
|
+
severity: hasCardContext ? "info" : "warning"
|
|
432
|
+
});
|
|
433
|
+
break;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
const hasLabelImport = /import\s.*Label.*from\s+['"]@\/components\/ui\//.test(code);
|
|
437
|
+
const inputCount = (code.match(INPUT_TAG_RE) || []).length;
|
|
438
|
+
const labelForCount = (code.match(LABEL_FOR_RE) || []).length;
|
|
439
|
+
if (hasLabelImport && inputCount > 0 && labelForCount === 0) {
|
|
440
|
+
issues.push({
|
|
441
|
+
line: 0,
|
|
442
|
+
type: "MISSING_LABEL",
|
|
443
|
+
message: "Inputs found but no Label with htmlFor \u2014 every input must have a visible label",
|
|
444
|
+
severity: "error"
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
if (!hasLabelImport && inputCount > 0 && !/<label\b/i.test(code)) {
|
|
448
|
+
issues.push({
|
|
449
|
+
line: 0,
|
|
450
|
+
type: "MISSING_LABEL",
|
|
451
|
+
message: "Inputs found but no Label component \u2014 import Label and add htmlFor on each input",
|
|
452
|
+
severity: "error"
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
const hasPlaceholder = /placeholder\s*=/.test(code);
|
|
456
|
+
if (hasPlaceholder && inputCount > 0 && labelForCount === 0 && !/<label\b/i.test(code) && !/<Label\b/.test(code)) {
|
|
457
|
+
issues.push({
|
|
458
|
+
line: 0,
|
|
459
|
+
type: "PLACEHOLDER_ONLY_LABEL",
|
|
460
|
+
message: "Inputs use placeholder only \u2014 add visible Label with htmlFor (placeholder is not a substitute)",
|
|
461
|
+
severity: "error"
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
const hasInteractive = /<Button\b|<button\b|<a\b/.test(code);
|
|
465
|
+
const hasFocusVisible = /focus-visible:/.test(code);
|
|
466
|
+
const usesShadcnButton = /import\s.*Button.*from\s+['"]@\/components\/ui\//.test(code);
|
|
467
|
+
if (hasInteractive && !hasFocusVisible && !usesShadcnButton) {
|
|
468
|
+
issues.push({
|
|
469
|
+
line: 0,
|
|
470
|
+
type: "MISSING_FOCUS_VISIBLE",
|
|
471
|
+
message: "Interactive elements without focus-visible styles \u2014 add focus-visible:ring-2 focus-visible:ring-ring",
|
|
472
|
+
severity: "info"
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
const hasTableOrList = /<Table\b|<table\b|\.map\s*\(|<ul\b|<ol\b/.test(code);
|
|
476
|
+
const hasEmptyCheck = /\.length\s*[=!]==?\s*0|\.length\s*>\s*0|\.length\s*<\s*1|No\s+\w+\s+found|empty|no results|EmptyState|empty state/i.test(
|
|
477
|
+
code
|
|
478
|
+
);
|
|
479
|
+
if (hasTableOrList && !hasEmptyCheck) {
|
|
480
|
+
issues.push({
|
|
481
|
+
line: 0,
|
|
482
|
+
type: "NO_EMPTY_STATE",
|
|
483
|
+
message: "List/table/grid without empty state handling \u2014 add friendly message + primary action",
|
|
484
|
+
severity: "warning"
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
const hasDataFetching = /fetch\s*\(|useQuery|useSWR|useEffect\s*\([^)]*fetch|getData|loadData/i.test(code);
|
|
488
|
+
const hasLoadingPattern = /skeleton|Skeleton|spinner|Spinner|isLoading|loading|Loading/.test(code);
|
|
489
|
+
if (hasDataFetching && !hasLoadingPattern) {
|
|
490
|
+
issues.push({
|
|
491
|
+
line: 0,
|
|
492
|
+
type: "NO_LOADING_STATE",
|
|
493
|
+
message: "Page with data fetching but no loading/skeleton pattern \u2014 add skeleton or spinner",
|
|
494
|
+
severity: "warning"
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
const hasGenericError = /Something went wrong|"Error"|'Error'|>Error<\//.test(code) || /error\.message\s*\|\|\s*["']Error["']/.test(code);
|
|
498
|
+
if (hasGenericError) {
|
|
499
|
+
issues.push({
|
|
500
|
+
line: 0,
|
|
501
|
+
type: "EMPTY_ERROR_MESSAGE",
|
|
502
|
+
message: "Generic error message detected \u2014 use what happened + why + what to do next",
|
|
503
|
+
severity: "warning"
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
const hasDestructive = /variant\s*=\s*["']destructive["']|Delete|Remove/.test(code);
|
|
507
|
+
const hasConfirm = /AlertDialog|Dialog.*confirm|confirm\s*\(|onConfirm|are you sure/i.test(code);
|
|
508
|
+
if (hasDestructive && !hasConfirm) {
|
|
509
|
+
issues.push({
|
|
510
|
+
line: 0,
|
|
511
|
+
type: "DESTRUCTIVE_NO_CONFIRM",
|
|
512
|
+
message: "Destructive action without confirmation dialog \u2014 add confirm before execution",
|
|
513
|
+
severity: "warning"
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
const hasFormSubmit = /<form\b|onSubmit|type\s*=\s*["']submit["']/.test(code);
|
|
517
|
+
const hasFeedback = /toast|success|error|Saved|Saving|saving|setError|setSuccess/i.test(code);
|
|
518
|
+
if (hasFormSubmit && !hasFeedback) {
|
|
519
|
+
issues.push({
|
|
520
|
+
line: 0,
|
|
521
|
+
type: "FORM_NO_FEEDBACK",
|
|
522
|
+
message: 'Form with submit but no success/error feedback pattern \u2014 add "Saving..." then "Saved" or error',
|
|
523
|
+
severity: "info"
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
const hasNav = /<nav\b|NavLink|navigation|sidebar.*link|Sidebar.*link/i.test(code);
|
|
527
|
+
const hasActiveState = /pathname|active|current|aria-current|data-active/.test(code);
|
|
528
|
+
if (hasNav && !hasActiveState) {
|
|
529
|
+
issues.push({
|
|
530
|
+
line: 0,
|
|
531
|
+
type: "NAV_NO_ACTIVE_STATE",
|
|
532
|
+
message: "Navigation without active/current page indicator \u2014 add active state for current route",
|
|
533
|
+
severity: "info"
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
if (validRoutes && validRoutes.length > 0) {
|
|
537
|
+
const routeSet = new Set(validRoutes);
|
|
538
|
+
routeSet.add("#");
|
|
539
|
+
const lines = code.split("\n");
|
|
540
|
+
const linkHrefRe = /href\s*=\s*["'](\/[a-z0-9/-]*)["']/gi;
|
|
541
|
+
for (let i = 0; i < lines.length; i++) {
|
|
542
|
+
let match;
|
|
543
|
+
while ((match = linkHrefRe.exec(lines[i])) !== null) {
|
|
544
|
+
const target = match[1];
|
|
545
|
+
if (target === "/" || target.startsWith("/design-system") || target.startsWith("/api") || target.startsWith("/#"))
|
|
546
|
+
continue;
|
|
547
|
+
if (!routeSet.has(target)) {
|
|
548
|
+
issues.push({
|
|
549
|
+
line: i + 1,
|
|
550
|
+
type: "BROKEN_INTERNAL_LINK",
|
|
551
|
+
message: `Link to "${target}" \u2014 route does not exist in project`,
|
|
552
|
+
severity: "warning"
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
const linkBlockRe = /<(?:Link|a)\b[^>]*>[\s\S]*?<\/(?:Link|a)>/g;
|
|
559
|
+
let linkMatch;
|
|
560
|
+
while ((linkMatch = linkBlockRe.exec(code)) !== null) {
|
|
561
|
+
const block = linkMatch[0];
|
|
562
|
+
if (/<(?:Button|button)\b/.test(block) && !/asChild/.test(block)) {
|
|
563
|
+
issues.push({
|
|
564
|
+
line: 0,
|
|
565
|
+
type: "NESTED_INTERACTIVE",
|
|
566
|
+
message: "Button inside Link without asChild \u2014 causes DOM nesting error. Use <Button asChild><Link>...</Link></Button> instead",
|
|
567
|
+
severity: "error"
|
|
568
|
+
});
|
|
569
|
+
break;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
const nestedAnchorRe = /<a\b[^>]*>[\s\S]*?<a\b/;
|
|
573
|
+
if (nestedAnchorRe.test(code)) {
|
|
574
|
+
issues.push({
|
|
575
|
+
line: 0,
|
|
576
|
+
type: "NESTED_INTERACTIVE",
|
|
577
|
+
message: "Nested <a> tags \u2014 causes DOM nesting error. Remove inner anchor or restructure",
|
|
578
|
+
severity: "error"
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
const linkWithoutHrefRe = /<(?:Link|a)\b(?![^>]*\bhref\s*=)[^>]*>/g;
|
|
582
|
+
let linkNoHrefMatch;
|
|
583
|
+
while ((linkNoHrefMatch = linkWithoutHrefRe.exec(code)) !== null) {
|
|
584
|
+
const matchLine = code.slice(0, linkNoHrefMatch.index).split("\n").length;
|
|
585
|
+
issues.push({
|
|
586
|
+
line: matchLine,
|
|
587
|
+
type: "LINK_MISSING_HREF",
|
|
588
|
+
message: "<Link> or <a> without href prop \u2014 causes Next.js runtime error. Add href attribute.",
|
|
589
|
+
severity: "error"
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
issues.push(...detectComponentIssues(code));
|
|
593
|
+
return issues;
|
|
594
|
+
}
|
|
595
|
+
function resolveHref(linkText, context) {
|
|
596
|
+
if (!context) return "/";
|
|
597
|
+
const text = linkText.trim().toLowerCase();
|
|
598
|
+
if (context.linkMap) {
|
|
599
|
+
for (const [label, route] of Object.entries(context.linkMap)) {
|
|
600
|
+
if (label.toLowerCase() === text) return route;
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
if (context.knownRoutes) {
|
|
604
|
+
const cleaned = text.replace(/^(back\s+to|go\s+to|view\s+all|see\s+all|return\s+to)\s+/i, "").trim();
|
|
605
|
+
for (const route of context.knownRoutes) {
|
|
606
|
+
const slug = route.split("/").filter(Boolean).pop() || "";
|
|
607
|
+
const routeName = slug.replace(/[-_]/g, " ");
|
|
608
|
+
if (routeName && cleaned === routeName) return route;
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
return "/";
|
|
612
|
+
}
|
|
613
|
+
function replaceRawColors(classes, colorMap) {
|
|
614
|
+
let changed = false;
|
|
615
|
+
let result = classes;
|
|
616
|
+
const accentColorRe = /\b((?:(?:hover|focus|active|group-hover|focus-visible|focus-within):)?)(bg|text|border|ring|outline|from|to|via)-(emerald|blue|violet|indigo|purple|teal|cyan|sky|rose|amber|red|green|yellow|pink|orange|fuchsia|lime)-(\d+)\b/g;
|
|
617
|
+
result = result.replace(accentColorRe, (m, statePrefix, prefix, color, shade) => {
|
|
618
|
+
const bare = m.replace(statePrefix, "");
|
|
619
|
+
if (colorMap[bare]) {
|
|
620
|
+
changed = true;
|
|
621
|
+
return statePrefix + colorMap[bare];
|
|
622
|
+
}
|
|
623
|
+
const n = parseInt(shade);
|
|
624
|
+
const isDestructive = color === "red";
|
|
625
|
+
if (prefix === "bg") {
|
|
626
|
+
if (n >= 500 && n <= 700) {
|
|
627
|
+
changed = true;
|
|
628
|
+
return statePrefix + (isDestructive ? "bg-destructive" : "bg-primary");
|
|
629
|
+
}
|
|
630
|
+
if (n >= 100 && n <= 200) {
|
|
631
|
+
changed = true;
|
|
632
|
+
return statePrefix + (isDestructive ? "bg-destructive/10" : "bg-primary/10");
|
|
633
|
+
}
|
|
634
|
+
if (n >= 300 && n <= 400) {
|
|
635
|
+
changed = true;
|
|
636
|
+
return statePrefix + (isDestructive ? "bg-destructive/20" : "bg-primary/20");
|
|
637
|
+
}
|
|
638
|
+
if (n >= 800) {
|
|
639
|
+
changed = true;
|
|
640
|
+
return statePrefix + "bg-muted";
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
if (prefix === "text") {
|
|
644
|
+
if (n >= 400 && n <= 600) {
|
|
645
|
+
changed = true;
|
|
646
|
+
return statePrefix + (isDestructive ? "text-destructive" : "text-primary");
|
|
647
|
+
}
|
|
648
|
+
if (n >= 100 && n <= 300) {
|
|
649
|
+
changed = true;
|
|
650
|
+
return statePrefix + "text-foreground";
|
|
651
|
+
}
|
|
652
|
+
if (n >= 700) {
|
|
653
|
+
changed = true;
|
|
654
|
+
return statePrefix + "text-foreground";
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
if (prefix === "border" || prefix === "ring" || prefix === "outline") {
|
|
658
|
+
changed = true;
|
|
659
|
+
return statePrefix + (isDestructive ? `${prefix}-destructive` : `${prefix}-primary`);
|
|
660
|
+
}
|
|
661
|
+
if (prefix === "from" || prefix === "to" || prefix === "via") {
|
|
662
|
+
changed = true;
|
|
663
|
+
if (n >= 100 && n <= 300)
|
|
664
|
+
return statePrefix + (isDestructive ? `${prefix}-destructive/20` : `${prefix}-primary/20`);
|
|
665
|
+
return statePrefix + (isDestructive ? `${prefix}-destructive` : `${prefix}-primary`);
|
|
666
|
+
}
|
|
667
|
+
return m;
|
|
668
|
+
});
|
|
669
|
+
const neutralColorRe = /\b((?:(?:hover|focus|active|group-hover|focus-visible|focus-within):)?)(bg|text|border|ring|outline)-(zinc|slate|gray|neutral|stone)-(\d+)\b/g;
|
|
670
|
+
result = result.replace(neutralColorRe, (m, statePrefix, prefix, _color, shade) => {
|
|
671
|
+
const bare = m.replace(statePrefix, "");
|
|
672
|
+
if (colorMap[bare]) {
|
|
673
|
+
changed = true;
|
|
674
|
+
return statePrefix + colorMap[bare];
|
|
675
|
+
}
|
|
676
|
+
const n = parseInt(shade);
|
|
677
|
+
if (prefix === "bg") {
|
|
678
|
+
if (n >= 800) {
|
|
679
|
+
changed = true;
|
|
680
|
+
return statePrefix + "bg-background";
|
|
681
|
+
}
|
|
682
|
+
if (n >= 100 && n <= 300) {
|
|
683
|
+
changed = true;
|
|
684
|
+
return statePrefix + "bg-muted";
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
if (prefix === "text") {
|
|
688
|
+
if (n >= 100 && n <= 300) {
|
|
689
|
+
changed = true;
|
|
690
|
+
return statePrefix + "text-foreground";
|
|
691
|
+
}
|
|
692
|
+
if (n >= 400 && n <= 600) {
|
|
693
|
+
changed = true;
|
|
694
|
+
return statePrefix + "text-muted-foreground";
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
if (prefix === "border" || prefix === "ring" || prefix === "outline") {
|
|
698
|
+
changed = true;
|
|
699
|
+
return statePrefix + `${prefix === "border" ? "border-border" : `${prefix}-ring`}`;
|
|
700
|
+
}
|
|
701
|
+
return m;
|
|
702
|
+
});
|
|
703
|
+
return { result, changed };
|
|
704
|
+
}
|
|
705
|
+
async function autoFixCode(code, context) {
|
|
706
|
+
const fixes = [];
|
|
707
|
+
let fixed = code;
|
|
708
|
+
const beforeQuoteFix = fixed;
|
|
709
|
+
fixed = fixed.replace(/\\'(\s*[}\],])/g, "'$1");
|
|
710
|
+
fixed = fixed.replace(/(:\s*'.+)\\'(\s*)$/gm, "$1'$2");
|
|
711
|
+
if (fixed !== beforeQuoteFix) {
|
|
712
|
+
fixes.push("fixed escaped closing quotes in strings");
|
|
713
|
+
}
|
|
714
|
+
const beforeEntityFix = fixed;
|
|
715
|
+
const isInsideAttrValue = (line, idx) => {
|
|
716
|
+
let inQuote = false;
|
|
717
|
+
let inAttr = false;
|
|
718
|
+
for (let i = 0; i < idx; i++) {
|
|
719
|
+
if (line[i] === "=" && line[i + 1] === '"') {
|
|
720
|
+
inAttr = true;
|
|
721
|
+
inQuote = true;
|
|
722
|
+
i++;
|
|
723
|
+
} else if (inAttr && line[i] === '"') {
|
|
724
|
+
inAttr = false;
|
|
725
|
+
inQuote = false;
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
return inQuote;
|
|
729
|
+
};
|
|
730
|
+
fixed = fixed.split("\n").map((line) => {
|
|
731
|
+
let l = line;
|
|
732
|
+
l = l.replace(/<=/g, (m, offset) => isInsideAttrValue(line, offset) ? m : "<=");
|
|
733
|
+
l = l.replace(/>=/g, (m, offset) => isInsideAttrValue(line, offset) ? m : ">=");
|
|
734
|
+
l = l.replace(/&&/g, (m, offset) => isInsideAttrValue(line, offset) ? m : "&&");
|
|
735
|
+
l = l.replace(
|
|
736
|
+
/([\w)\]])\s*<\s*([\w(])/g,
|
|
737
|
+
(m, p1, p2, offset) => isInsideAttrValue(line, offset) ? m : `${p1} < ${p2}`
|
|
738
|
+
);
|
|
739
|
+
l = l.replace(
|
|
740
|
+
/([\w)\]])\s*>\s*([\w(])/g,
|
|
741
|
+
(m, p1, p2, offset) => isInsideAttrValue(line, offset) ? m : `${p1} > ${p2}`
|
|
742
|
+
);
|
|
743
|
+
return l;
|
|
744
|
+
}).join("\n");
|
|
745
|
+
if (fixed !== beforeEntityFix) {
|
|
746
|
+
fixes.push("Fixed syntax issues");
|
|
747
|
+
}
|
|
748
|
+
const beforeLtFix = fixed;
|
|
749
|
+
fixed = fixed.replace(/>([^<{}\n]*)<(\d)/g, ">$1<$2");
|
|
750
|
+
fixed = fixed.replace(/>([^<{}\n]*)<([^/a-zA-Z!{>\n])/g, ">$1<$2");
|
|
751
|
+
if (fixed !== beforeLtFix) {
|
|
752
|
+
fixes.push("escaped < in JSX text content");
|
|
753
|
+
}
|
|
754
|
+
if (/className="[^"]*\btext-base\b[^"]*"/.test(fixed)) {
|
|
755
|
+
fixed = fixed.replace(/className="([^"]*)\btext-base\b([^"]*)"/g, 'className="$1text-sm$2"');
|
|
756
|
+
fixes.push("text-base \u2192 text-sm");
|
|
757
|
+
}
|
|
758
|
+
if (/CardTitle[^>]*className="[^"]*text-(lg|xl|2xl)/.test(fixed)) {
|
|
759
|
+
fixed = fixed.replace(/(CardTitle[^>]*className="[^"]*)text-(lg|xl|2xl)\b/g, "$1");
|
|
760
|
+
fixes.push("large text in CardTitle \u2192 removed");
|
|
761
|
+
}
|
|
762
|
+
if (/className="[^"]*\bshadow-(md|lg|xl|2xl)\b[^"]*"/.test(fixed)) {
|
|
763
|
+
fixed = fixed.replace(/className="([^"]*)\bshadow-(md|lg|xl|2xl)\b([^"]*)"/g, 'className="$1shadow-sm$3"');
|
|
764
|
+
fixes.push("heavy shadow \u2192 shadow-sm");
|
|
765
|
+
}
|
|
766
|
+
const hasHooks = /\b(useState|useEffect|useRef|useCallback|useMemo|useReducer|useContext)\b/.test(fixed);
|
|
767
|
+
const hasEvents = /\b(onClick|onChange|onSubmit|onBlur|onFocus|onKeyDown|onKeyUp|onMouseEnter|onMouseLeave|onScroll|onInput)\s*[={]/.test(
|
|
768
|
+
fixed
|
|
769
|
+
);
|
|
770
|
+
const hasUseClient = /^['"]use client['"]/.test(fixed.trim());
|
|
771
|
+
if ((hasHooks || hasEvents) && !hasUseClient) {
|
|
772
|
+
fixed = `'use client'
|
|
773
|
+
|
|
774
|
+
${fixed}`;
|
|
775
|
+
fixes.push('added "use client" (client features detected)');
|
|
776
|
+
}
|
|
777
|
+
if (/^['"]use client['"]/.test(fixed.trim()) && /\bexport\s+const\s+metadata\s*:\s*Metadata\s*=\s*\{/.test(fixed)) {
|
|
778
|
+
const metaMatch = fixed.match(/\bexport\s+const\s+metadata\s*:\s*Metadata\s*=\s*\{/);
|
|
779
|
+
if (metaMatch) {
|
|
780
|
+
const start = fixed.indexOf(metaMatch[0]);
|
|
781
|
+
const open = fixed.indexOf("{", start);
|
|
782
|
+
let depth = 1, i = open + 1;
|
|
783
|
+
while (i < fixed.length && depth > 0) {
|
|
784
|
+
if (fixed[i] === "{") depth++;
|
|
785
|
+
else if (fixed[i] === "}") depth--;
|
|
786
|
+
i++;
|
|
787
|
+
}
|
|
788
|
+
const tail = fixed.slice(i);
|
|
789
|
+
const semi = tail.match(/^\s*;/);
|
|
790
|
+
const removeEnd = semi ? i + (semi.index + semi[0].length) : i;
|
|
791
|
+
fixed = (fixed.slice(0, start) + fixed.slice(removeEnd)).replace(/\n{3,}/g, "\n\n").trim();
|
|
792
|
+
fixes.push('removed metadata export (conflicts with "use client")');
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
const lines = fixed.split("\n");
|
|
796
|
+
let hasReplacedButton = false;
|
|
797
|
+
for (let i = 0; i < lines.length; i++) {
|
|
798
|
+
if (!/<button\b/.test(lines[i])) continue;
|
|
799
|
+
if (lines[i].includes("aria-label")) continue;
|
|
800
|
+
if (/onClick=\{.*copy/i.test(lines[i])) continue;
|
|
801
|
+
const block = lines.slice(i, i + 5).join(" ");
|
|
802
|
+
if (block.includes("aria-label") || /onClick=\{.*copy/i.test(block)) continue;
|
|
803
|
+
lines[i] = lines[i].replace(/<button\b/g, "<Button");
|
|
804
|
+
hasReplacedButton = true;
|
|
805
|
+
}
|
|
806
|
+
if (hasReplacedButton) {
|
|
807
|
+
fixed = lines.join("\n");
|
|
808
|
+
fixed = fixed.replace(/<\/button>/g, (_match, _offset) => {
|
|
809
|
+
return "</Button>";
|
|
810
|
+
});
|
|
811
|
+
const openCount = (fixed.match(/<Button\b/g) || []).length;
|
|
812
|
+
const closeCount = (fixed.match(/<\/Button>/g) || []).length;
|
|
813
|
+
if (closeCount > openCount) {
|
|
814
|
+
let excess = closeCount - openCount;
|
|
815
|
+
fixed = fixed.replace(/<\/Button>/g, (m) => {
|
|
816
|
+
if (excess > 0) {
|
|
817
|
+
excess--;
|
|
818
|
+
return "</button>";
|
|
819
|
+
}
|
|
820
|
+
return m;
|
|
821
|
+
});
|
|
822
|
+
}
|
|
823
|
+
const hasButtonImport = /import\s.*\bButton\b.*from\s+['"]@\/components\/ui\/button['"]/.test(fixed);
|
|
824
|
+
if (!hasButtonImport) {
|
|
825
|
+
const lastImportIdx = fixed.lastIndexOf("\nimport ");
|
|
826
|
+
if (lastImportIdx !== -1) {
|
|
827
|
+
const lineEnd = fixed.indexOf("\n", lastImportIdx + 1);
|
|
828
|
+
fixed = fixed.slice(0, lineEnd + 1) + "import { Button } from '@/components/ui/button'\n" + fixed.slice(lineEnd + 1);
|
|
829
|
+
} else {
|
|
830
|
+
const insertAfter = hasUseClient ? fixed.indexOf("\n") + 1 : 0;
|
|
831
|
+
fixed = fixed.slice(0, insertAfter) + "import { Button } from '@/components/ui/button'\n" + fixed.slice(insertAfter);
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
fixes.push("<button> \u2192 <Button> (with import)");
|
|
835
|
+
}
|
|
836
|
+
const colorMap = {
|
|
837
|
+
"bg-zinc-950": "bg-background",
|
|
838
|
+
"bg-zinc-900": "bg-background",
|
|
839
|
+
"bg-slate-950": "bg-background",
|
|
840
|
+
"bg-slate-900": "bg-background",
|
|
841
|
+
"bg-gray-950": "bg-background",
|
|
842
|
+
"bg-gray-900": "bg-background",
|
|
843
|
+
"bg-zinc-800": "bg-muted",
|
|
844
|
+
"bg-slate-800": "bg-muted",
|
|
845
|
+
"bg-gray-800": "bg-muted",
|
|
846
|
+
"bg-zinc-100": "bg-muted",
|
|
847
|
+
"bg-slate-100": "bg-muted",
|
|
848
|
+
"bg-gray-100": "bg-muted",
|
|
849
|
+
"bg-white": "bg-background",
|
|
850
|
+
"bg-black": "bg-background",
|
|
851
|
+
"text-white": "text-foreground",
|
|
852
|
+
"text-black": "text-foreground",
|
|
853
|
+
"text-zinc-100": "text-foreground",
|
|
854
|
+
"text-zinc-200": "text-foreground",
|
|
855
|
+
"text-slate-100": "text-foreground",
|
|
856
|
+
"text-gray-100": "text-foreground",
|
|
857
|
+
"text-zinc-400": "text-muted-foreground",
|
|
858
|
+
"text-zinc-500": "text-muted-foreground",
|
|
859
|
+
"text-slate-400": "text-muted-foreground",
|
|
860
|
+
"text-slate-500": "text-muted-foreground",
|
|
861
|
+
"text-gray-400": "text-muted-foreground",
|
|
862
|
+
"text-gray-500": "text-muted-foreground",
|
|
863
|
+
"border-zinc-700": "border-border",
|
|
864
|
+
"border-zinc-800": "border-border",
|
|
865
|
+
"border-slate-700": "border-border",
|
|
866
|
+
"border-gray-700": "border-border",
|
|
867
|
+
"border-zinc-200": "border-border",
|
|
868
|
+
"border-slate-200": "border-border",
|
|
869
|
+
"border-gray-200": "border-border"
|
|
870
|
+
};
|
|
871
|
+
const isCodeContext = (classes) => /\bfont-mono\b/.test(classes) || /\bbg-zinc-950\b/.test(classes) || /\bbg-zinc-900\b/.test(classes);
|
|
872
|
+
const isInsideTerminalBlock = (offset) => {
|
|
873
|
+
const preceding = fixed.slice(Math.max(0, offset - 600), offset);
|
|
874
|
+
if (!/(bg-zinc-950|bg-zinc-900)/.test(preceding)) return false;
|
|
875
|
+
if (!/font-mono/.test(preceding)) return false;
|
|
876
|
+
const lastClose = Math.max(preceding.lastIndexOf("</div>"), preceding.lastIndexOf("</section>"));
|
|
877
|
+
const lastTerminal = Math.max(preceding.lastIndexOf("bg-zinc-950"), preceding.lastIndexOf("bg-zinc-900"));
|
|
878
|
+
return lastTerminal > lastClose;
|
|
879
|
+
};
|
|
880
|
+
let hadColorFix = false;
|
|
881
|
+
fixed = fixed.replace(/className="([^"]*)"/g, (fullMatch, classes, offset) => {
|
|
882
|
+
if (isCodeContext(classes)) return fullMatch;
|
|
883
|
+
if (isInsideTerminalBlock(offset)) return fullMatch;
|
|
884
|
+
const { result, changed } = replaceRawColors(classes, colorMap);
|
|
885
|
+
if (changed) hadColorFix = true;
|
|
886
|
+
if (result !== classes) return `className="${result}"`;
|
|
887
|
+
return fullMatch;
|
|
888
|
+
});
|
|
889
|
+
fixed = fixed.replace(/(?:cn|clsx|cva)\(([^()]*(?:\([^()]*\)[^()]*)*)\)/g, (fullMatch, args) => {
|
|
890
|
+
const replaced = args.replace(/"([^"]*)"/g, (_qm, inner) => {
|
|
891
|
+
const { result, changed } = replaceRawColors(inner, colorMap);
|
|
892
|
+
if (changed) hadColorFix = true;
|
|
893
|
+
return `"${result}"`;
|
|
894
|
+
});
|
|
895
|
+
if (replaced !== args) return fullMatch.replace(args, replaced);
|
|
896
|
+
return fullMatch;
|
|
897
|
+
});
|
|
898
|
+
fixed = fixed.replace(/className='([^']*)'/g, (fullMatch, classes, offset) => {
|
|
899
|
+
if (isCodeContext(classes)) return fullMatch;
|
|
900
|
+
if (isInsideTerminalBlock(offset)) return fullMatch;
|
|
901
|
+
const { result, changed } = replaceRawColors(classes, colorMap);
|
|
902
|
+
if (changed) hadColorFix = true;
|
|
903
|
+
if (result !== classes) return `className='${result}'`;
|
|
904
|
+
return fullMatch;
|
|
905
|
+
});
|
|
906
|
+
fixed = fixed.replace(/className=\{`([^`]*)`\}/g, (fullMatch, inner) => {
|
|
907
|
+
const { result, changed } = replaceRawColors(inner, colorMap);
|
|
908
|
+
if (changed) hadColorFix = true;
|
|
909
|
+
if (result !== inner) return `className={\`${result}\`}`;
|
|
910
|
+
return fullMatch;
|
|
911
|
+
});
|
|
912
|
+
if (hadColorFix) fixes.push("raw colors \u2192 semantic tokens");
|
|
913
|
+
const selectRe = /<select\b[^>]*>([\s\S]*?)<\/select>/g;
|
|
914
|
+
let hadSelectFix = false;
|
|
915
|
+
fixed = fixed.replace(selectRe, (_match, inner) => {
|
|
916
|
+
const options = [];
|
|
917
|
+
const optionRe = /<option\s+value="([^"]*)"[^>]*>([^<]*)<\/option>/g;
|
|
918
|
+
let optMatch;
|
|
919
|
+
while ((optMatch = optionRe.exec(inner)) !== null) {
|
|
920
|
+
options.push({ value: optMatch[1], label: optMatch[2] });
|
|
921
|
+
}
|
|
922
|
+
if (options.length === 0) return _match;
|
|
923
|
+
hadSelectFix = true;
|
|
924
|
+
const items = options.map((o) => ` <SelectItem value="${o.value}">${o.label}</SelectItem>`).join("\n");
|
|
925
|
+
return `<Select>
|
|
926
|
+
<SelectTrigger>
|
|
927
|
+
<SelectValue placeholder="Select..." />
|
|
928
|
+
</SelectTrigger>
|
|
929
|
+
<SelectContent>
|
|
930
|
+
${items}
|
|
931
|
+
</SelectContent>
|
|
932
|
+
</Select>`;
|
|
933
|
+
});
|
|
934
|
+
if (hadSelectFix) {
|
|
935
|
+
fixes.push("<select> \u2192 shadcn Select");
|
|
936
|
+
const selectImport = `import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select'`;
|
|
937
|
+
if (!/from\s+['"]@\/components\/ui\/select['"]/.test(fixed)) {
|
|
938
|
+
const replaced = fixed.replace(
|
|
939
|
+
/(import\s+\{[^}]*\}\s+from\s+['"]@\/components\/ui\/[^'"]+['"])/,
|
|
940
|
+
`$1
|
|
941
|
+
${selectImport}`
|
|
942
|
+
);
|
|
943
|
+
if (replaced !== fixed) {
|
|
944
|
+
fixed = replaced;
|
|
945
|
+
} else {
|
|
946
|
+
fixed = selectImport + "\n" + fixed;
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
const lucideImportMatch = fixed.match(/import\s*\{([^}]+)\}\s*from\s*["']lucide-react["']/);
|
|
951
|
+
if (lucideImportMatch) {
|
|
952
|
+
let lucideExports = null;
|
|
953
|
+
try {
|
|
954
|
+
const { createRequire } = await import("module");
|
|
955
|
+
const require2 = createRequire(process.cwd() + "/package.json");
|
|
956
|
+
const lr = require2("lucide-react");
|
|
957
|
+
lucideExports = new Set(Object.keys(lr).filter((k) => /^[A-Z]/.test(k)));
|
|
958
|
+
} catch {
|
|
959
|
+
}
|
|
960
|
+
if (lucideExports) {
|
|
961
|
+
const nonLucideImports = /* @__PURE__ */ new Set();
|
|
962
|
+
for (const m of fixed.matchAll(/import\s*\{([^}]+)\}\s*from\s*["'](?!lucide-react)([^"']+)["']/g)) {
|
|
963
|
+
m[1].split(",").map((s) => s.trim()).filter(Boolean).forEach((n) => nonLucideImports.add(n));
|
|
964
|
+
}
|
|
965
|
+
const iconNames = lucideImportMatch[1].split(",").map((s) => s.trim()).filter(Boolean);
|
|
966
|
+
const duplicates = iconNames.filter((name) => nonLucideImports.has(name));
|
|
967
|
+
let newImport = lucideImportMatch[1];
|
|
968
|
+
for (const dup of duplicates) {
|
|
969
|
+
newImport = newImport.replace(new RegExp(`\\b${dup}\\b,?\\s*`), "");
|
|
970
|
+
fixes.push(`removed ${dup} from lucide import (conflicts with UI component import)`);
|
|
971
|
+
}
|
|
972
|
+
const invalid = iconNames.filter((name) => !lucideExports.has(name) && !nonLucideImports.has(name));
|
|
973
|
+
if (invalid.length > 0) {
|
|
974
|
+
const fallback = "Circle";
|
|
975
|
+
for (const bad of invalid) {
|
|
976
|
+
const re = new RegExp(`\\b${bad}\\b`, "g");
|
|
977
|
+
newImport = newImport.replace(re, fallback);
|
|
978
|
+
fixed = fixed.replace(re, fallback);
|
|
979
|
+
}
|
|
980
|
+
fixes.push(`invalid lucide icons \u2192 ${fallback}: ${invalid.join(", ")}`);
|
|
981
|
+
}
|
|
982
|
+
if (duplicates.length > 0 || invalid.length > 0) {
|
|
983
|
+
const importedNames = [
|
|
984
|
+
...new Set(
|
|
985
|
+
newImport.split(",").map((s) => s.trim()).filter(Boolean)
|
|
986
|
+
)
|
|
987
|
+
];
|
|
988
|
+
const originalImportLine = lucideImportMatch[0];
|
|
989
|
+
fixed = fixed.replace(originalImportLine, `import { ${importedNames.join(", ")} } from "lucide-react"`);
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
const lucideImportMatch2 = fixed.match(/import\s*\{([^}]+)\}\s*from\s*["']lucide-react["']/);
|
|
994
|
+
if (lucideImportMatch2) {
|
|
995
|
+
let lucideExports2 = null;
|
|
996
|
+
try {
|
|
997
|
+
const { createRequire } = await import("module");
|
|
998
|
+
const req = createRequire(process.cwd() + "/package.json");
|
|
999
|
+
const lr = req("lucide-react");
|
|
1000
|
+
lucideExports2 = new Set(Object.keys(lr).filter((k) => /^[A-Z]/.test(k)));
|
|
1001
|
+
} catch {
|
|
1002
|
+
}
|
|
1003
|
+
if (lucideExports2) {
|
|
1004
|
+
const allImportedNames = /* @__PURE__ */ new Set();
|
|
1005
|
+
for (const m of fixed.matchAll(/import\s*\{([^}]+)\}\s*from/g)) {
|
|
1006
|
+
m[1].split(",").map((s) => s.trim()).filter(Boolean).forEach((n) => allImportedNames.add(n));
|
|
1007
|
+
}
|
|
1008
|
+
for (const m of fixed.matchAll(/import\s+([A-Z]\w+)\s+from/g)) {
|
|
1009
|
+
allImportedNames.add(m[1]);
|
|
1010
|
+
}
|
|
1011
|
+
const lucideImported = new Set(
|
|
1012
|
+
lucideImportMatch2[1].split(",").map((s) => s.trim()).filter(Boolean)
|
|
1013
|
+
);
|
|
1014
|
+
const jsxIconRefs = [...new Set([...fixed.matchAll(/<([A-Z][a-zA-Z]*Icon)\s/g)].map((m) => m[1]))];
|
|
1015
|
+
const missing = [];
|
|
1016
|
+
for (const ref of jsxIconRefs) {
|
|
1017
|
+
if (allImportedNames.has(ref)) continue;
|
|
1018
|
+
if (fixed.includes(`function ${ref}`) || fixed.includes(`const ${ref}`)) continue;
|
|
1019
|
+
const baseName = ref.replace(/Icon$/, "");
|
|
1020
|
+
if (lucideExports2.has(ref)) {
|
|
1021
|
+
missing.push(ref);
|
|
1022
|
+
lucideImported.add(ref);
|
|
1023
|
+
} else if (lucideExports2.has(baseName)) {
|
|
1024
|
+
const re = new RegExp(`\\b${ref}\\b`, "g");
|
|
1025
|
+
fixed = fixed.replace(re, baseName);
|
|
1026
|
+
missing.push(baseName);
|
|
1027
|
+
lucideImported.add(baseName);
|
|
1028
|
+
fixes.push(`renamed ${ref} \u2192 ${baseName} (lucide-react)`);
|
|
1029
|
+
} else {
|
|
1030
|
+
const fallback = "Circle";
|
|
1031
|
+
const re = new RegExp(`\\b${ref}\\b`, "g");
|
|
1032
|
+
fixed = fixed.replace(re, fallback);
|
|
1033
|
+
lucideImported.add(fallback);
|
|
1034
|
+
fixes.push(`unknown icon ${ref} \u2192 ${fallback}`);
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
if (missing.length > 0) {
|
|
1038
|
+
const allNames = [...lucideImported];
|
|
1039
|
+
const origLine = lucideImportMatch2[0];
|
|
1040
|
+
fixed = fixed.replace(origLine, `import { ${allNames.join(", ")} } from "lucide-react"`);
|
|
1041
|
+
fixes.push(`added missing lucide imports: ${missing.join(", ")}`);
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
const lucideNamesMatch = fixed.match(/import\s*\{([^}]+)\}\s*from\s*["']lucide-react["']/);
|
|
1046
|
+
if (lucideNamesMatch) {
|
|
1047
|
+
const lucideNames = new Set(
|
|
1048
|
+
lucideNamesMatch[1].split(",").map((s) => s.trim()).filter(Boolean)
|
|
1049
|
+
);
|
|
1050
|
+
const beforeShrinkFix = fixed;
|
|
1051
|
+
for (const iconName of lucideNames) {
|
|
1052
|
+
const iconRe = new RegExp(`(<${iconName}\\s[^>]*className=")([^"]*)(")`, "g");
|
|
1053
|
+
fixed = fixed.replace(iconRe, (_m, pre, classes, post) => {
|
|
1054
|
+
if (/\bshrink-0\b/.test(classes)) return _m;
|
|
1055
|
+
return `${pre}${classes} shrink-0${post}`;
|
|
1056
|
+
});
|
|
1057
|
+
}
|
|
1058
|
+
if (fixed !== beforeShrinkFix) {
|
|
1059
|
+
fixes.push("added shrink-0 to icons");
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
const linkWithButtonRe = /(<Link\b[^>]*>)\s*(<Button\b(?![^>]*asChild)[^>]*>)([\s\S]*?)<\/Button>\s*<\/Link>/g;
|
|
1063
|
+
const beforeLinkFix = fixed;
|
|
1064
|
+
fixed = fixed.replace(linkWithButtonRe, (_match, linkOpen, buttonOpen, inner) => {
|
|
1065
|
+
const hrefMatch = linkOpen.match(/href="([^"]*)"/);
|
|
1066
|
+
const href = hrefMatch ? hrefMatch[1] : "/";
|
|
1067
|
+
const buttonWithAsChild = buttonOpen.replace("<Button", "<Button asChild");
|
|
1068
|
+
return `${buttonWithAsChild}<Link href="${href}">${inner.trim()}</Link></Button>`;
|
|
1069
|
+
});
|
|
1070
|
+
if (fixed !== beforeLinkFix) {
|
|
1071
|
+
fixes.push("Link>Button \u2192 Button asChild>Link (DOM nesting fix)");
|
|
1072
|
+
}
|
|
1073
|
+
const beforeAsChildFlex = fixed;
|
|
1074
|
+
fixed = fixed.replace(
|
|
1075
|
+
/(<Button\b[^>]*\basChild\b[^>]*>)\s*(<(?:Link|a)\b)([^>]*)(>)/g,
|
|
1076
|
+
(_match, btnOpen, childTag, childProps, close) => {
|
|
1077
|
+
if (/\binline-flex\b/.test(childProps)) return _match;
|
|
1078
|
+
if (/className="([^"]*)"/.test(childProps)) {
|
|
1079
|
+
const merged = childProps.replace(
|
|
1080
|
+
/className="([^"]*)"/,
|
|
1081
|
+
(_cm, classes) => `className="inline-flex items-center gap-2 ${classes}"`
|
|
1082
|
+
);
|
|
1083
|
+
return `${btnOpen}${childTag}${merged}${close}`;
|
|
1084
|
+
}
|
|
1085
|
+
return `${btnOpen}${childTag} className="inline-flex items-center gap-2"${close}`;
|
|
1086
|
+
}
|
|
1087
|
+
);
|
|
1088
|
+
if (fixed !== beforeAsChildFlex) {
|
|
1089
|
+
fixes.push("added inline-flex to Button asChild children (base-ui compat)");
|
|
1090
|
+
}
|
|
1091
|
+
const beforeLinkHrefFix = fixed;
|
|
1092
|
+
fixed = fixed.replace(/<(Link|a)\b(?![^>]*\bhref\s*=)([^>]*)>([\s\S]*?)<\/\1>/g, (_match, tag, attrs, children) => {
|
|
1093
|
+
const textContent = children.replace(/<[^>]*>/g, "").trim();
|
|
1094
|
+
const href = resolveHref(textContent, context);
|
|
1095
|
+
return `<${tag} href="${href}"${attrs}>${children}</${tag}>`;
|
|
1096
|
+
});
|
|
1097
|
+
fixed = fixed.replace(/<(Link|a)\b(?![^>]*\bhref\s*=)([^>]*)\/?>/g, '<$1 href="/"$2>');
|
|
1098
|
+
if (fixed !== beforeLinkHrefFix) {
|
|
1099
|
+
fixes.push("added href to <Link>/<a> missing href");
|
|
1100
|
+
}
|
|
1101
|
+
const { code: fixedByRules, fixes: ruleFixes } = applyComponentRules(fixed);
|
|
1102
|
+
if (ruleFixes.length > 0) {
|
|
1103
|
+
fixed = fixedByRules;
|
|
1104
|
+
fixes.push(...ruleFixes);
|
|
1105
|
+
}
|
|
1106
|
+
const beforeTabsFix = fixed;
|
|
1107
|
+
fixed = fixed.replace(
|
|
1108
|
+
/(<TabsTrigger\b[^>]*className=")([^"]*)(")/g,
|
|
1109
|
+
(_m, pre, classes, post) => {
|
|
1110
|
+
const cleaned = classes.replace(/\b(border-input|border\b|outline\b)\s*/g, "").trim();
|
|
1111
|
+
if (cleaned !== classes.trim()) return `${pre}${cleaned}${post}`;
|
|
1112
|
+
return _m;
|
|
1113
|
+
}
|
|
1114
|
+
);
|
|
1115
|
+
if (fixed !== beforeTabsFix) {
|
|
1116
|
+
fixes.push("stripped border from TabsTrigger (shadcn handles active state)");
|
|
1117
|
+
}
|
|
1118
|
+
const beforeJunkFix = fixed;
|
|
1119
|
+
fixed = fixed.replace(/className="([^"]*)"/g, (_match, classes) => {
|
|
1120
|
+
const cleaned = classes.split(/\s+/).filter((c) => c !== "-0").join(" ");
|
|
1121
|
+
if (cleaned !== classes.trim()) return `className="${cleaned}"`;
|
|
1122
|
+
return _match;
|
|
1123
|
+
});
|
|
1124
|
+
if (fixed !== beforeJunkFix) {
|
|
1125
|
+
fixes.push("removed junk classes (-0)");
|
|
1126
|
+
}
|
|
1127
|
+
fixed = fixed.replace(/className="([^"]*)"/g, (_match, inner) => {
|
|
1128
|
+
const cleaned = inner.replace(/\s{2,}/g, " ").trim();
|
|
1129
|
+
return `className="${cleaned}"`;
|
|
1130
|
+
});
|
|
1131
|
+
let imgCounter = 1;
|
|
1132
|
+
const beforeImgFix = fixed;
|
|
1133
|
+
fixed = fixed.replace(/["']\/api\/placeholder\/(\d+)\/(\d+)["']/g, (_m, w, h) => {
|
|
1134
|
+
return `"https://picsum.photos/${w}/${h}?random=${imgCounter++}"`;
|
|
1135
|
+
});
|
|
1136
|
+
fixed = fixed.replace(/["']\/placeholder-avatar[^"']*["']/g, () => {
|
|
1137
|
+
return `"https://i.pravatar.cc/150?u=user${imgCounter++}"`;
|
|
1138
|
+
});
|
|
1139
|
+
fixed = fixed.replace(/["']https?:\/\/via\.placeholder\.com\/(\d+)x?(\d*)(?:\/[^"']*)?\/?["']/g, (_m, w, h) => {
|
|
1140
|
+
const height = h || w;
|
|
1141
|
+
return `"https://picsum.photos/${w}/${height}?random=${imgCounter++}"`;
|
|
1142
|
+
});
|
|
1143
|
+
fixed = fixed.replace(/["']\/images\/[^"']+\.(?:jpg|jpeg|png|webp|gif)["']/g, () => {
|
|
1144
|
+
return `"https://picsum.photos/800/400?random=${imgCounter++}"`;
|
|
1145
|
+
});
|
|
1146
|
+
fixed = fixed.replace(/["']\/placeholder[^"']*\.(?:jpg|jpeg|png|webp)["']/g, () => {
|
|
1147
|
+
return `"https://picsum.photos/800/400?random=${imgCounter++}"`;
|
|
1148
|
+
});
|
|
1149
|
+
if (fixed !== beforeImgFix) {
|
|
1150
|
+
fixes.push("placeholder images \u2192 working URLs (picsum/pravatar)");
|
|
1151
|
+
}
|
|
1152
|
+
return { code: fixed, fixes };
|
|
1153
|
+
}
|
|
1154
|
+
function formatIssues(issues) {
|
|
1155
|
+
if (issues.length === 0) return "";
|
|
1156
|
+
const errors = issues.filter((i) => i.severity === "error");
|
|
1157
|
+
const warnings = issues.filter((i) => i.severity === "warning");
|
|
1158
|
+
const infos = issues.filter((i) => i.severity === "info");
|
|
1159
|
+
const lines = [];
|
|
1160
|
+
if (errors.length > 0) {
|
|
1161
|
+
lines.push(` \u274C ${errors.length} error(s):`);
|
|
1162
|
+
for (const e of errors) {
|
|
1163
|
+
lines.push(` L${e.line}: [${e.type}] ${e.message}`);
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
if (warnings.length > 0) {
|
|
1167
|
+
lines.push(` \u26A0\uFE0F ${warnings.length} warning(s):`);
|
|
1168
|
+
for (const w of warnings) {
|
|
1169
|
+
lines.push(` L${w.line}: [${w.type}] ${w.message}`);
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
if (infos.length > 0) {
|
|
1173
|
+
lines.push(` \u2139\uFE0F ${infos.length} info:`);
|
|
1174
|
+
for (const i of infos) {
|
|
1175
|
+
lines.push(` L${i.line}: [${i.type}] ${i.message}`);
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
return lines.join("\n");
|
|
1179
|
+
}
|
|
1180
|
+
function checkDesignConsistency(code) {
|
|
1181
|
+
const warnings = [];
|
|
1182
|
+
const hexPattern = /\[#[0-9a-fA-F]{3,8}\]/g;
|
|
1183
|
+
for (const match of code.matchAll(hexPattern)) {
|
|
1184
|
+
warnings.push({
|
|
1185
|
+
type: "hardcoded-color",
|
|
1186
|
+
message: `Hardcoded color ${match[0]} \u2014 use a design token (e.g., bg-primary) instead`
|
|
1187
|
+
});
|
|
1188
|
+
}
|
|
1189
|
+
const spacingPattern = /[pm][trblxy]?-\[\d+px\]/g;
|
|
1190
|
+
for (const match of code.matchAll(spacingPattern)) {
|
|
1191
|
+
warnings.push({
|
|
1192
|
+
type: "arbitrary-spacing",
|
|
1193
|
+
message: `Arbitrary spacing ${match[0]} \u2014 use Tailwind spacing scale instead`
|
|
1194
|
+
});
|
|
1195
|
+
}
|
|
1196
|
+
return warnings;
|
|
1197
|
+
}
|
|
1198
|
+
function verifyIncrementalEdit(before, after) {
|
|
1199
|
+
const issues = [];
|
|
1200
|
+
const hookPattern = /\buse[A-Z]\w+\s*\(/;
|
|
1201
|
+
if (hookPattern.test(after) && !after.includes("'use client'") && !after.includes('"use client"')) {
|
|
1202
|
+
issues.push({
|
|
1203
|
+
type: "missing-use-client",
|
|
1204
|
+
message: 'Code uses React hooks but missing "use client" directive'
|
|
1205
|
+
});
|
|
1206
|
+
}
|
|
1207
|
+
if (!after.includes("export default")) {
|
|
1208
|
+
issues.push({
|
|
1209
|
+
type: "missing-default-export",
|
|
1210
|
+
message: "Missing default export \u2014 page component must have a default export"
|
|
1211
|
+
});
|
|
1212
|
+
}
|
|
1213
|
+
const importRegex = /import\s+\{([^}]+)\}\s+from/g;
|
|
1214
|
+
const beforeImports = /* @__PURE__ */ new Set();
|
|
1215
|
+
const afterImports = /* @__PURE__ */ new Set();
|
|
1216
|
+
for (const match of before.matchAll(importRegex)) {
|
|
1217
|
+
match[1].split(",").forEach((s) => beforeImports.add(s.trim()));
|
|
1218
|
+
}
|
|
1219
|
+
for (const match of after.matchAll(importRegex)) {
|
|
1220
|
+
match[1].split(",").forEach((s) => afterImports.add(s.trim()));
|
|
1221
|
+
}
|
|
1222
|
+
for (const symbol of beforeImports) {
|
|
1223
|
+
if (!afterImports.has(symbol) && symbol.length > 0) {
|
|
1224
|
+
const codeWithoutImports = after.replace(/^import\s+.*$/gm, "");
|
|
1225
|
+
const symbolRegex = new RegExp(`\\b${symbol}\\b`);
|
|
1226
|
+
if (symbolRegex.test(codeWithoutImports)) {
|
|
1227
|
+
issues.push({
|
|
1228
|
+
type: "missing-import",
|
|
1229
|
+
symbol,
|
|
1230
|
+
message: `Import for "${symbol}" was removed but symbol is still used in code`
|
|
1231
|
+
});
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
return issues;
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
export {
|
|
1239
|
+
validatePageQuality,
|
|
1240
|
+
autoFixCode,
|
|
1241
|
+
formatIssues,
|
|
1242
|
+
checkDesignConsistency,
|
|
1243
|
+
verifyIncrementalEdit
|
|
1244
|
+
};
|