@getcoherent/cli 0.5.3 → 0.5.4

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 (2) hide show
  1. package/dist/index.js +1753 -1653
  2. package/package.json +2 -2
package/dist/index.js CHANGED
@@ -4517,6 +4517,7 @@ pageCode rules (shadcn/ui blocks quality):
4517
4517
  - BUTTON + LINK: The Button component supports asChild prop. To make a button that navigates, use <Button asChild><Link href="/path"><Plus className="size-4" /> Label</Link></Button>. Never nest <button> inside <Link> or vice versa without asChild.
4518
4518
  - Hover/focus on every interactive element (hover:bg-muted, focus-visible:ring-2 focus-visible:ring-ring).
4519
4519
  - LANGUAGE: Match the language of the user's request. English request \u2192 English page. Russian request \u2192 Russian page. Never switch languages.
4520
+ - NEVER use native HTML <select> or <option>. Always use Select, SelectTrigger, SelectValue, SelectContent, SelectItem from @/components/ui/select.
4520
4521
 
4521
4522
  NEXT.JS APP ROUTER RULE (CRITICAL \u2014 invalid code fails to compile):
4522
4523
  - "use client" and export const metadata are FORBIDDEN in the same file.
@@ -5042,1716 +5043,1785 @@ function fixGlobalsCss(projectRoot, config2) {
5042
5043
  writeFileSync7(layoutPath, layoutContent, "utf-8");
5043
5044
  }
5044
5045
 
5045
- // src/commands/chat/utils.ts
5046
- import { resolve as resolve5 } from "path";
5047
- import { existsSync as existsSync13, readFileSync as readFileSync8 } from "fs";
5048
- import { DesignSystemManager as DesignSystemManager3, loadManifest as loadManifest4 } from "@getcoherent/core";
5049
- import chalk8 from "chalk";
5050
- var MARKETING_ROUTES = /* @__PURE__ */ new Set(["", "landing", "pricing", "about", "contact", "blog", "features"]);
5051
- function isMarketingRoute(route) {
5052
- const slug = route.replace(/^\//, "").split("/")[0] || "";
5053
- return MARKETING_ROUTES.has(slug);
5054
- }
5055
- function routeToFsPath(projectRoot, route, isAuth) {
5056
- const slug = route.replace(/^\//, "");
5057
- if (isAuth) {
5058
- return resolve5(projectRoot, "app", "(auth)", slug || "login", "page.tsx");
5059
- }
5060
- if (!slug) {
5061
- return resolve5(projectRoot, "app", "page.tsx");
5062
- }
5063
- if (isMarketingRoute(route)) {
5064
- return resolve5(projectRoot, "app", slug, "page.tsx");
5065
- }
5066
- return resolve5(projectRoot, "app", "(app)", slug, "page.tsx");
5067
- }
5068
- function routeToRelPath(route, isAuth) {
5069
- const slug = route.replace(/^\//, "");
5070
- if (isAuth) {
5071
- return `app/(auth)/${slug || "login"}/page.tsx`;
5072
- }
5073
- if (!slug) {
5074
- return "app/page.tsx";
5075
- }
5076
- if (isMarketingRoute(route)) {
5077
- return `app/${slug}/page.tsx`;
5078
- }
5079
- return `app/(app)/${slug}/page.tsx`;
5080
- }
5081
- function deduplicatePages(pages) {
5082
- const normalize = (route) => route.replace(/\/$/, "").replace(/s$/, "").replace(/ue$/, "");
5083
- const seen = /* @__PURE__ */ new Map();
5084
- return pages.filter((page, idx) => {
5085
- const norm = normalize(page.route);
5086
- if (seen.has(norm)) return false;
5087
- seen.set(norm, idx);
5088
- return true;
5089
- });
5090
- }
5091
- function extractComponentIdsFromCode(code) {
5092
- const ids = /* @__PURE__ */ new Set();
5093
- const allMatches = code.matchAll(/@\/components\/((?:ui\/)?[a-z0-9-]+)/g);
5094
- for (const m of allMatches) {
5095
- if (!m[1]) continue;
5096
- let id = m[1];
5097
- if (id.startsWith("ui/")) id = id.slice(3);
5098
- if (id === "shared" || id.startsWith("shared/")) continue;
5099
- if (id) ids.add(id);
5046
+ // src/utils/quality-validator.ts
5047
+ var RAW_COLOR_RE = /(?:bg|text|border)-(gray|blue|red|green|yellow|purple|pink|indigo|orange|slate|zinc|stone|neutral|emerald|teal|cyan|sky|violet|fuchsia|rose|amber|lime)-\d+/g;
5048
+ var HEX_IN_CLASS_RE = /className="[^"]*#[0-9a-fA-F]{3,8}[^"]*"/g;
5049
+ var TEXT_BASE_RE = /\btext-base\b/g;
5050
+ var HEAVY_SHADOW_RE = /\bshadow-(md|lg|xl|2xl)\b/g;
5051
+ var SM_BREAKPOINT_RE = /\bsm:/g;
5052
+ var XL_BREAKPOINT_RE = /\bxl:/g;
5053
+ var XXL_BREAKPOINT_RE = /\b2xl:/g;
5054
+ var LARGE_CARD_TITLE_RE = /CardTitle[^>]*className="[^"]*text-(lg|xl|2xl)/g;
5055
+ var RAW_BUTTON_RE = /<button\b/g;
5056
+ var RAW_INPUT_RE = /<input\b/g;
5057
+ var RAW_SELECT_RE = /<select\b/g;
5058
+ var NATIVE_CHECKBOX_RE = /<input[^>]*type\s*=\s*["']checkbox["']/g;
5059
+ var NATIVE_TABLE_RE = /<table\b/g;
5060
+ var PLACEHOLDER_PATTERNS = [
5061
+ />\s*Lorem ipsum\b/i,
5062
+ />\s*Card content\s*</i,
5063
+ />\s*Your (?:text|content) here\s*</i,
5064
+ />\s*Description\s*</,
5065
+ />\s*Title\s*</,
5066
+ /placeholder\s*text/i
5067
+ ];
5068
+ var GENERIC_BUTTON_LABELS = />\s*(Submit|OK|Click here|Press here|Go)\s*</i;
5069
+ var IMG_WITHOUT_ALT_RE = /<img\b(?![^>]*\balt\s*=)[^>]*>/g;
5070
+ var INPUT_TAG_RE = /<(?:Input|input)\b[^>]*>/g;
5071
+ var LABEL_FOR_RE = /<Label\b[^>]*htmlFor\s*=/;
5072
+ function isInsideCommentOrString(line, matchIndex) {
5073
+ const commentIdx = line.indexOf("//");
5074
+ if (commentIdx !== -1 && commentIdx < matchIndex) return true;
5075
+ let inSingle = false;
5076
+ let inDouble = false;
5077
+ let inTemplate = false;
5078
+ for (let i = 0; i < matchIndex; i++) {
5079
+ const ch = line[i];
5080
+ const prev = i > 0 ? line[i - 1] : "";
5081
+ if (prev === "\\") continue;
5082
+ if (ch === "'" && !inDouble && !inTemplate) inSingle = !inSingle;
5083
+ if (ch === '"' && !inSingle && !inTemplate) inDouble = !inDouble;
5084
+ if (ch === "`" && !inSingle && !inDouble) inTemplate = !inTemplate;
5100
5085
  }
5101
- return ids;
5086
+ return inSingle || inDouble || inTemplate;
5102
5087
  }
5103
- async function warnInlineDuplicates(projectRoot, pageName, pageCode, manifest) {
5104
- const sectionOrWidget = manifest.shared.filter((e) => e.type === "section" || e.type === "widget");
5105
- if (sectionOrWidget.length === 0) return;
5106
- for (const e of sectionOrWidget) {
5107
- const kebab = e.file.replace(/^components\/shared\//, "").replace(/\.tsx$/, "");
5108
- const hasImport = pageCode.includes(`@/components/shared/${kebab}`);
5109
- if (hasImport) continue;
5110
- const sameNameAsTag = new RegExp(`<\\/?${e.name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[\\s>]`).test(pageCode);
5111
- if (sameNameAsTag) {
5112
- console.log(
5113
- chalk8.yellow(
5114
- `
5115
- \u26A0 Page "${pageName}" contains inline code similar to ${e.id} (${e.name}). Consider using the shared component instead.`
5116
- )
5117
- );
5118
- continue;
5119
- }
5120
- try {
5121
- const fullPath = resolve5(projectRoot, e.file);
5122
- const sharedSnippet = (await readFile(fullPath)).slice(0, 600);
5123
- const sharedTokens = new Set(sharedSnippet.match(/\b[a-zA-Z0-9-]{4,}\b/g) ?? []);
5124
- const pageTokens = pageCode.match(/\b[a-zA-Z0-9-]+\b/g) ?? [];
5125
- let overlap = 0;
5126
- for (const t of sharedTokens) {
5127
- if (pageTokens.includes(t)) overlap++;
5088
+ function checkLines(code, pattern, type, message, severity, skipCommentsAndStrings = false) {
5089
+ const issues = [];
5090
+ const lines = code.split("\n");
5091
+ let inBlockComment = false;
5092
+ for (let i = 0; i < lines.length; i++) {
5093
+ const line = lines[i];
5094
+ if (skipCommentsAndStrings) {
5095
+ if (inBlockComment) {
5096
+ const endIdx = line.indexOf("*/");
5097
+ if (endIdx !== -1) {
5098
+ inBlockComment = false;
5099
+ }
5100
+ continue;
5128
5101
  }
5129
- if (overlap >= 12 && sharedTokens.size >= 10) {
5130
- console.log(
5131
- chalk8.yellow(
5132
- `
5133
- \u26A0 Page "${pageName}" contains inline code similar to ${e.id} (${e.name}). Consider using the shared component instead.`
5134
- )
5135
- );
5102
+ const blockStart = line.indexOf("/*");
5103
+ if (blockStart !== -1 && !line.includes("*/")) {
5104
+ inBlockComment = true;
5105
+ continue;
5136
5106
  }
5137
- } catch {
5138
- }
5139
- }
5140
- }
5141
- async function loadConfig(configPath) {
5142
- if (!existsSync13(configPath)) {
5143
- throw new Error(
5144
- `Design system config not found at ${configPath}
5145
- Run "coherent init" first to create a project.`
5146
- );
5147
- }
5148
- const manager = new DesignSystemManager3(configPath);
5149
- await manager.load();
5150
- return manager.getConfig();
5151
- }
5152
- function requireProject() {
5153
- const project = findConfig();
5154
- if (!project) {
5155
- exitNotCoherent();
5156
- }
5157
- warnIfVolatile(project.root);
5158
- return project;
5159
- }
5160
- async function resolveTargetFlags(message, options, config2, projectRoot) {
5161
- if (options.component) {
5162
- const manifest = await loadManifest4(projectRoot);
5163
- const target = options.component;
5164
- const entry = manifest.shared.find(
5165
- (s) => s.name.toLowerCase() === target.toLowerCase() || s.id.toLowerCase() === target.toLowerCase()
5166
- );
5167
- if (entry) {
5168
- const filePath = resolve5(projectRoot, entry.file);
5169
- let currentCode = "";
5170
- if (existsSync13(filePath)) {
5171
- currentCode = readFileSync8(filePath, "utf-8");
5107
+ let m;
5108
+ pattern.lastIndex = 0;
5109
+ while ((m = pattern.exec(line)) !== null) {
5110
+ if (!isInsideCommentOrString(line, m.index)) {
5111
+ issues.push({ line: i + 1, type, message, severity });
5112
+ break;
5113
+ }
5172
5114
  }
5173
- const codeSnippet = currentCode ? `
5174
-
5175
- Current code of ${entry.name}:
5176
- \`\`\`tsx
5177
- ${currentCode}
5178
- \`\`\`` : "";
5179
- return `Modify the shared component ${entry.name} (${entry.id}, file: ${entry.file}): ${message}. Read the current code below and apply the requested changes. Return the full updated component code as pageCode.${codeSnippet}`;
5180
- }
5181
- console.log(chalk8.yellow(`
5182
- \u26A0\uFE0F Component "${target}" not found in shared components.`));
5183
- console.log(chalk8.dim(" Available: " + manifest.shared.map((s) => `${s.id} ${s.name}`).join(", ")));
5184
- console.log(chalk8.dim(" Proceeding with message as-is...\n"));
5185
- }
5186
- if (options.page) {
5187
- const target = options.page;
5188
- const page = config2.pages.find(
5189
- (p) => p.name.toLowerCase() === target.toLowerCase() || p.id.toLowerCase() === target.toLowerCase() || p.route === target || p.route === "/" + target
5190
- );
5191
- if (page) {
5192
- const relPath = page.route === "/" ? "app/page.tsx" : `app${page.route}/page.tsx`;
5193
- const filePath = resolve5(projectRoot, relPath);
5194
- let currentCode = "";
5195
- if (existsSync13(filePath)) {
5196
- currentCode = readFileSync8(filePath, "utf-8");
5115
+ } else {
5116
+ pattern.lastIndex = 0;
5117
+ if (pattern.test(line)) {
5118
+ issues.push({ line: i + 1, type, message, severity });
5197
5119
  }
5198
- const codeSnippet = currentCode ? `
5199
-
5200
- Current code of ${page.name} page:
5201
- \`\`\`tsx
5202
- ${currentCode}
5203
- \`\`\`` : "";
5204
- return `Update page "${page.name}" (id: ${page.id}, route: ${page.route}, file: ${relPath}): ${message}. Read the current code below and apply the requested changes.${codeSnippet}`;
5205
5120
  }
5206
- console.log(chalk8.yellow(`
5207
- \u26A0\uFE0F Page "${target}" not found.`));
5208
- console.log(chalk8.dim(" Available: " + config2.pages.map((p) => `${p.id} (${p.route})`).join(", ")));
5209
- console.log(chalk8.dim(" Proceeding with message as-is...\n"));
5210
- }
5211
- if (options.token) {
5212
- const target = options.token;
5213
- return `Change design token "${target}": ${message}. Update the token value in design-system.config.ts and ensure globals.css reflects the change.`;
5214
5121
  }
5215
- return message;
5122
+ return issues;
5216
5123
  }
5217
-
5218
- // src/commands/chat/request-parser.ts
5219
- var AUTH_FLOW_PATTERNS = {
5220
- "/login": ["/register", "/forgot-password"],
5221
- "/signin": ["/register", "/forgot-password"],
5222
- "/signup": ["/login"],
5223
- "/register": ["/login"],
5224
- "/forgot-password": ["/login", "/reset-password"],
5225
- "/reset-password": ["/login"]
5226
- };
5227
- var PAGE_RELATIONSHIP_RULES = [
5228
- {
5229
- trigger: /\/(products|catalog|marketplace|listings|shop|store)\b/i,
5230
- related: [{ id: "product-detail", name: "Product Detail", route: "/products/[id]" }]
5231
- },
5232
- {
5233
- trigger: /\/(blog|news|articles|posts)\b/i,
5234
- related: [{ id: "article-detail", name: "Article", route: "/blog/[slug]" }]
5235
- },
5236
- {
5237
- trigger: /\/(campaigns|ads|ad-campaigns)\b/i,
5238
- related: [{ id: "campaign-detail", name: "Campaign Detail", route: "/campaigns/[id]" }]
5239
- },
5240
- {
5241
- trigger: /\/(dashboard|admin)\b/i,
5242
- related: [{ id: "settings", name: "Settings", route: "/settings" }]
5243
- },
5244
- {
5245
- trigger: /\/pricing\b/i,
5246
- related: [{ id: "checkout", name: "Checkout", route: "/checkout" }]
5247
- }
5248
- ];
5249
- function extractInternalLinks(code) {
5250
- const links = /* @__PURE__ */ new Set();
5251
- const hrefRe = /href\s*=\s*["'](\/[a-z0-9/-]*)["']/gi;
5252
- let m;
5253
- while ((m = hrefRe.exec(code)) !== null) {
5254
- const route = m[1];
5255
- if (route === "/" || route.startsWith("/design-system") || route.startsWith("/#") || route.startsWith("/api"))
5256
- continue;
5257
- links.add(route);
5258
- }
5259
- return [...links];
5260
- }
5261
- function inferRelatedPages(plannedPages) {
5262
- const plannedRoutes = new Set(plannedPages.map((p) => p.route));
5263
- const inferred = [];
5264
- for (const { route } of plannedPages) {
5265
- const authRelated = AUTH_FLOW_PATTERNS[route];
5266
- if (authRelated) {
5267
- for (const rel of authRelated) {
5268
- if (!plannedRoutes.has(rel)) {
5269
- const slug = rel.slice(1);
5270
- const name = slug.split("-").map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
5271
- inferred.push({ id: slug, name, route: rel });
5272
- plannedRoutes.add(rel);
5273
- }
5274
- }
5275
- }
5276
- for (const rule of PAGE_RELATIONSHIP_RULES) {
5277
- if (rule.trigger.test(route)) {
5278
- for (const rel of rule.related) {
5279
- if (!plannedRoutes.has(rel.route)) {
5280
- inferred.push(rel);
5281
- plannedRoutes.add(rel.route);
5282
- }
5283
- }
5284
- }
5285
- }
5286
- }
5287
- return inferred;
5288
- }
5289
- function impliesFullWebsite(message) {
5290
- return /\b(create|build|make|design)\b.{0,80}\b(website|web\s*site|web\s*app|application|app|platform|portal|marketplace|site)\b/i.test(
5291
- message
5292
- );
5293
- }
5294
- function extractPageNamesFromMessage(message) {
5295
- const pages = [];
5296
- const known = {
5297
- home: "/",
5298
- landing: "/",
5299
- dashboard: "/dashboard",
5300
- about: "/about",
5301
- "about us": "/about",
5302
- contact: "/contact",
5303
- contacts: "/contacts",
5304
- pricing: "/pricing",
5305
- settings: "/settings",
5306
- account: "/account",
5307
- "personal account": "/account",
5308
- registration: "/registration",
5309
- signup: "/signup",
5310
- "sign up": "/signup",
5311
- login: "/login",
5312
- "sign in": "/login",
5313
- catalogue: "/catalogue",
5314
- catalog: "/catalog",
5315
- blog: "/blog",
5316
- portfolio: "/portfolio",
5317
- features: "/features",
5318
- services: "/services",
5319
- faq: "/faq",
5320
- team: "/team"
5124
+ function validatePageQuality(code, validRoutes) {
5125
+ const issues = [];
5126
+ const allLines = code.split("\n");
5127
+ const isTerminalContext = (lineNum) => {
5128
+ const start = Math.max(0, lineNum - 20);
5129
+ const nearby = allLines.slice(start, lineNum).join(" ");
5130
+ if (/font-mono/.test(allLines[lineNum - 1] || "")) return true;
5131
+ if (/bg-zinc-950|bg-zinc-900/.test(nearby) && /font-mono/.test(nearby)) return true;
5132
+ return false;
5321
5133
  };
5322
- const lower = message.toLowerCase();
5323
- for (const [key, route] of Object.entries(known)) {
5324
- if (lower.includes(key)) {
5325
- const name = key.split(" ").map((w) => w[0].toUpperCase() + w.slice(1)).join(" ");
5326
- const id = route.slice(1) || "home";
5327
- if (!pages.some((p) => p.route === route)) {
5328
- pages.push({ name, id, route });
5329
- }
5330
- }
5331
- }
5332
- return pages;
5333
- }
5334
- function normalizeRequest(request, config2) {
5335
- const changes = request.changes;
5336
- const VALID_TYPES = [
5337
- "update-token",
5338
- "add-component",
5339
- "modify-component",
5340
- "add-layout-block",
5341
- "modify-layout-block",
5342
- "add-page",
5343
- "update-page",
5344
- "update-navigation",
5345
- "link-shared",
5346
- "promote-and-link"
5347
- ];
5348
- if (!VALID_TYPES.includes(request.type)) {
5349
- return { error: `Unknown action "${request.type}". Valid: ${VALID_TYPES.join(", ")}` };
5350
- }
5351
- const findPage = (target) => config2.pages.find(
5352
- (p) => p.id === target || p.route === target || p.name?.toLowerCase() === String(target).toLowerCase()
5134
+ issues.push(
5135
+ ...checkLines(
5136
+ code,
5137
+ RAW_COLOR_RE,
5138
+ "RAW_COLOR",
5139
+ "Raw Tailwind color detected \u2014 use semantic tokens (bg-primary, text-muted-foreground, etc.)",
5140
+ "error"
5141
+ ).filter((issue) => !isTerminalContext(issue.line))
5353
5142
  );
5354
- switch (request.type) {
5355
- case "update-page": {
5356
- const page = findPage(request.target);
5357
- if (!page && changes?.pageCode) {
5358
- const targetStr = String(request.target);
5359
- const id = targetStr.replace(/^\//, "") || "home";
5360
- return {
5361
- ...request,
5362
- type: "add-page",
5363
- target: "new",
5364
- changes: {
5365
- id,
5366
- name: changes.name || id.charAt(0).toUpperCase() + id.slice(1) || "Home",
5367
- route: targetStr.startsWith("/") ? targetStr : `/${targetStr}`,
5368
- ...changes
5369
- }
5370
- };
5371
- }
5372
- if (!page) {
5373
- const available = config2.pages.map((p) => `${p.name} (${p.route})`).join(", ");
5374
- return { error: `Page "${request.target}" not found. Available: ${available || "none"}` };
5375
- }
5376
- if (page.id !== request.target) {
5377
- return { ...request, target: page.id };
5378
- }
5379
- break;
5380
- }
5381
- case "add-page": {
5382
- if (!changes) break;
5383
- let route = changes.route || "";
5384
- if (route && !route.startsWith("/")) route = `/${route}`;
5385
- if (route) changes.route = route;
5386
- const existingByRoute = config2.pages.find((p) => p.route === route);
5387
- if (existingByRoute && route) {
5388
- return {
5389
- ...request,
5390
- type: "update-page",
5391
- target: existingByRoute.id
5392
- };
5393
- }
5394
- if (!changes.id && changes.name) {
5395
- changes.id = String(changes.name).toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "");
5396
- }
5397
- if (!changes.id && route) {
5398
- changes.id = route.replace(/^\//, "") || "home";
5399
- }
5400
- break;
5401
- }
5402
- case "modify-component": {
5403
- const componentId = request.target;
5404
- const existingComp = config2.components.find((c) => c.id === componentId);
5405
- if (!existingComp) {
5406
- return {
5407
- ...request,
5408
- type: "add-component",
5409
- target: "new"
5410
- };
5411
- }
5412
- if (changes) {
5413
- if (typeof changes.id === "string" && changes.id !== componentId) {
5414
- const targetExists = config2.components.some((c) => c.id === changes.id);
5415
- if (!targetExists) {
5416
- return { ...request, type: "add-component", target: "new" };
5417
- }
5418
- return {
5419
- error: `Cannot change component "${componentId}" to "${changes.id}" \u2014 "${changes.id}" already exists.`
5420
- };
5421
- }
5422
- if (typeof changes.name === "string") {
5423
- const newName = changes.name.toLowerCase();
5424
- const curName = existingComp.name.toLowerCase();
5425
- const curId = componentId.toLowerCase();
5426
- const nameOk = newName === curName || newName === curId || newName.includes(curId) || curId.includes(newName);
5427
- if (!nameOk) {
5428
- delete changes.name;
5429
- }
5430
- }
5431
- }
5432
- break;
5433
- }
5434
- case "add-component": {
5435
- if (changes) {
5436
- const shadcn = changes.shadcnComponent;
5437
- const id = changes.id;
5438
- if (shadcn && id && id !== shadcn) {
5439
- changes.id = shadcn;
5440
- }
5441
- }
5442
- break;
5443
- }
5444
- case "link-shared": {
5445
- if (changes) {
5446
- const page = findPage(request.target);
5447
- if (!page) {
5448
- const available = config2.pages.map((p) => `${p.name} (${p.route})`).join(", ");
5449
- return { error: `Page "${request.target}" not found for link-shared. Available: ${available || "none"}` };
5450
- }
5451
- if (page.id !== request.target) {
5452
- return { ...request, target: page.id };
5453
- }
5454
- }
5455
- break;
5456
- }
5457
- case "promote-and-link": {
5458
- const sourcePage = findPage(request.target);
5459
- if (!sourcePage) {
5460
- const available = config2.pages.map((p) => `${p.name} (${p.route})`).join(", ");
5461
- return {
5462
- error: `Source page "${request.target}" not found for promote-and-link. Available: ${available || "none"}`
5463
- };
5464
- }
5465
- if (sourcePage.id !== request.target) {
5466
- return { ...request, target: sourcePage.id };
5467
- }
5468
- break;
5469
- }
5470
- }
5471
- return request;
5472
- }
5473
- function applyDefaults(request) {
5474
- if (request.type === "add-page" && request.changes && typeof request.changes === "object") {
5475
- const changes = request.changes;
5476
- const now = (/* @__PURE__ */ new Date()).toISOString();
5477
- const name = changes.name || "New Page";
5478
- let id = changes.id || name.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "");
5479
- if (!/^[a-z]/.test(id)) id = `page-${id}`;
5480
- const route = changes.route || `/${id}`;
5481
- const hasPageCode = typeof changes.pageCode === "string" && changes.pageCode.trim() !== "";
5482
- const base = {
5483
- id,
5484
- name,
5485
- route: route.startsWith("/") ? route : `/${route}`,
5486
- layout: changes.layout || "centered",
5487
- title: changes.title || name,
5488
- description: changes.description || `${name} page`,
5489
- createdAt: changes.createdAt || now,
5490
- updatedAt: changes.updatedAt || now,
5491
- requiresAuth: changes.requiresAuth ?? false,
5492
- noIndex: changes.noIndex ?? false
5493
- };
5494
- const sections = Array.isArray(changes.sections) ? changes.sections.map((section, idx) => ({
5495
- id: section.id || `section-${idx}`,
5496
- name: section.name || `Section ${idx + 1}`,
5497
- componentId: section.componentId || "button",
5498
- order: typeof section.order === "number" ? section.order : idx,
5499
- props: section.props || {}
5500
- })) : [];
5501
- return {
5502
- ...request,
5503
- changes: {
5504
- ...base,
5505
- sections,
5506
- ...hasPageCode ? { pageCode: changes.pageCode, generatedWithPageCode: true } : {},
5507
- ...changes.pageType ? { pageType: changes.pageType } : {},
5508
- ...changes.structuredContent ? { structuredContent: changes.structuredContent } : {}
5509
- }
5510
- };
5511
- }
5512
- if (request.type === "add-component" && request.changes && typeof request.changes === "object") {
5513
- const changes = request.changes;
5514
- const now = (/* @__PURE__ */ new Date()).toISOString();
5515
- const validSizeNames = ["xs", "sm", "md", "lg", "xl"];
5516
- let normalizedVariants = [];
5517
- if (Array.isArray(changes.variants)) {
5518
- normalizedVariants = changes.variants.map((v) => {
5519
- if (typeof v === "string") return { name: v, className: "" };
5520
- if (v && typeof v === "object" && "name" in v) {
5521
- return {
5522
- name: v.name,
5523
- className: v.className ?? ""
5524
- };
5525
- }
5526
- return { name: "default", className: "" };
5527
- });
5528
- }
5529
- let normalizedSizes = [];
5530
- if (Array.isArray(changes.sizes)) {
5531
- normalizedSizes = changes.sizes.map((s) => {
5532
- if (typeof s === "string") {
5533
- const name = validSizeNames.includes(s) ? s : "md";
5534
- return { name, className: "" };
5535
- }
5536
- if (s && typeof s === "object" && "name" in s) {
5537
- const raw = s.name;
5538
- const name = validSizeNames.includes(raw) ? raw : "md";
5539
- return { name, className: s.className ?? "" };
5540
- }
5541
- return { name: "md", className: "" };
5542
- });
5543
- }
5544
- return {
5545
- ...request,
5546
- changes: {
5547
- ...changes,
5548
- variants: normalizedVariants,
5549
- sizes: normalizedSizes,
5550
- createdAt: now,
5551
- updatedAt: now
5552
- }
5553
- };
5554
- }
5555
- if (request.type === "modify-component" && request.changes && typeof request.changes === "object") {
5556
- const changes = request.changes;
5557
- const validSizeNames = ["xs", "sm", "md", "lg", "xl"];
5558
- let normalizedVariants;
5559
- if (Array.isArray(changes.variants)) {
5560
- normalizedVariants = changes.variants.map((v) => {
5561
- if (typeof v === "string") return { name: v, className: "" };
5562
- if (v && typeof v === "object" && "name" in v) {
5563
- return {
5564
- name: v.name,
5565
- className: v.className ?? ""
5566
- };
5567
- }
5568
- return { name: "default", className: "" };
5569
- });
5570
- }
5571
- let normalizedSizes;
5572
- if (Array.isArray(changes.sizes)) {
5573
- normalizedSizes = changes.sizes.map((s) => {
5574
- if (typeof s === "string") {
5575
- const name = validSizeNames.includes(s) ? s : "md";
5576
- return { name, className: "" };
5577
- }
5578
- if (s && typeof s === "object" && "name" in s) {
5579
- const raw = s.name;
5580
- const name = validSizeNames.includes(raw) ? raw : "md";
5581
- return { name, className: s.className ?? "" };
5582
- }
5583
- return { name: "md", className: "" };
5584
- });
5585
- }
5586
- return {
5587
- ...request,
5588
- changes: {
5589
- ...changes,
5590
- ...normalizedVariants !== void 0 && { variants: normalizedVariants },
5591
- ...normalizedSizes !== void 0 && { sizes: normalizedSizes }
5592
- }
5593
- };
5594
- }
5595
- return request;
5596
- }
5597
-
5598
- // src/utils/page-analyzer.ts
5599
- var FORM_COMPONENTS = /* @__PURE__ */ new Set(["Input", "Textarea", "Label", "Select", "Checkbox", "Switch"]);
5600
- var VISUAL_WORDS = /\b(grid lines?|glow|radial|gradient|blur|shadow|overlay|animation|particles?|dots?|vertical|horizontal|decorat|behind|background|divider|spacer|wrapper|container|inner|outer|absolute|relative|translate|opacity|z-index|transition)\b/i;
5601
- function analyzePageCode(code) {
5602
- return {
5603
- sections: extractSections(code),
5604
- componentUsage: extractComponentUsage(code),
5605
- iconCount: extractIconCount(code),
5606
- layoutPattern: inferLayoutPattern(code),
5607
- hasForm: detectFormUsage(code),
5608
- analyzedAt: (/* @__PURE__ */ new Date()).toISOString()
5609
- };
5610
- }
5611
- function extractSections(code) {
5612
- const sections = [];
5613
- const seen = /* @__PURE__ */ new Set();
5614
- const commentRe = /\{\/\*\s*(.+?)\s*\*\/\}/g;
5615
- let m;
5616
- while ((m = commentRe.exec(code)) !== null) {
5617
- const raw = m[1].trim();
5618
- const name = raw.replace(/[─━—–]+/g, "").replace(/\s*section\s*$/i, "").replace(/^section\s*:\s*/i, "").trim();
5619
- if (!name || name.length <= 1 || name.length >= 40) continue;
5620
- if (seen.has(name.toLowerCase())) continue;
5621
- const wordCount = name.split(/\s+/).length;
5622
- if (wordCount > 5) continue;
5623
- if (/[{}()=<>;:`"']/.test(name)) continue;
5624
- if (/^[a-z]/.test(name) && wordCount > 2) continue;
5625
- if (VISUAL_WORDS.test(name)) continue;
5626
- seen.add(name.toLowerCase());
5627
- sections.push({ name, order: sections.length });
5143
+ issues.push(
5144
+ ...checkLines(
5145
+ code,
5146
+ HEX_IN_CLASS_RE,
5147
+ "HEX_IN_CLASS",
5148
+ "Hex color in className \u2014 use CSS variables via semantic tokens",
5149
+ "error"
5150
+ )
5151
+ );
5152
+ issues.push(
5153
+ ...checkLines(code, TEXT_BASE_RE, "TEXT_BASE", "text-base detected \u2014 use text-sm as base font size", "warning")
5154
+ );
5155
+ issues.push(
5156
+ ...checkLines(code, HEAVY_SHADOW_RE, "HEAVY_SHADOW", "Heavy shadow detected \u2014 use shadow-sm or none", "warning")
5157
+ );
5158
+ issues.push(
5159
+ ...checkLines(
5160
+ code,
5161
+ SM_BREAKPOINT_RE,
5162
+ "SM_BREAKPOINT",
5163
+ "sm: breakpoint \u2014 consider if md:/lg: is sufficient",
5164
+ "info"
5165
+ )
5166
+ );
5167
+ issues.push(
5168
+ ...checkLines(
5169
+ code,
5170
+ XL_BREAKPOINT_RE,
5171
+ "XL_BREAKPOINT",
5172
+ "xl: breakpoint \u2014 consider if md:/lg: is sufficient",
5173
+ "info"
5174
+ )
5175
+ );
5176
+ issues.push(
5177
+ ...checkLines(
5178
+ code,
5179
+ XXL_BREAKPOINT_RE,
5180
+ "XXL_BREAKPOINT",
5181
+ "2xl: breakpoint \u2014 rarely needed, consider xl: instead",
5182
+ "warning"
5183
+ )
5184
+ );
5185
+ issues.push(
5186
+ ...checkLines(
5187
+ code,
5188
+ LARGE_CARD_TITLE_RE,
5189
+ "LARGE_CARD_TITLE",
5190
+ "Large text on CardTitle \u2014 use text-sm font-medium",
5191
+ "warning"
5192
+ )
5193
+ );
5194
+ const codeLines = code.split("\n");
5195
+ issues.push(
5196
+ ...checkLines(
5197
+ code,
5198
+ RAW_BUTTON_RE,
5199
+ "NATIVE_BUTTON",
5200
+ "Native <button> \u2014 use Button from @/components/ui/button",
5201
+ "error",
5202
+ true
5203
+ ).filter((issue) => {
5204
+ const nearby = codeLines.slice(Math.max(0, issue.line - 1), issue.line + 5).join(" ");
5205
+ if (nearby.includes("aria-label")) return false;
5206
+ if (/onClick=\{.*copy/i.test(nearby)) return false;
5207
+ return true;
5208
+ })
5209
+ );
5210
+ issues.push(
5211
+ ...checkLines(
5212
+ code,
5213
+ RAW_SELECT_RE,
5214
+ "NATIVE_SELECT",
5215
+ "Native <select> \u2014 use Select from @/components/ui/select",
5216
+ "error",
5217
+ true
5218
+ )
5219
+ );
5220
+ issues.push(
5221
+ ...checkLines(
5222
+ code,
5223
+ NATIVE_CHECKBOX_RE,
5224
+ "NATIVE_CHECKBOX",
5225
+ 'Native <input type="checkbox"> \u2014 use Switch or Checkbox from @/components/ui/switch or @/components/ui/checkbox',
5226
+ "error",
5227
+ true
5228
+ )
5229
+ );
5230
+ issues.push(
5231
+ ...checkLines(
5232
+ code,
5233
+ NATIVE_TABLE_RE,
5234
+ "NATIVE_TABLE",
5235
+ "Native <table> \u2014 use Table, TableHeader, TableBody, etc. from @/components/ui/table",
5236
+ "warning",
5237
+ true
5238
+ )
5239
+ );
5240
+ const hasInputImport = /import\s.*Input.*from\s+['"]@\/components\/ui\//.test(code);
5241
+ if (!hasInputImport) {
5242
+ issues.push(
5243
+ ...checkLines(
5244
+ code,
5245
+ RAW_INPUT_RE,
5246
+ "RAW_INPUT",
5247
+ "Raw <input> element \u2014 import and use Input from @/components/ui/input",
5248
+ "warning",
5249
+ true
5250
+ )
5251
+ );
5628
5252
  }
5629
- if (sections.length === 0) {
5630
- const sectionTagRe = /<section[^>]*>[\s\S]*?<h[12][^>]*>\s*([^<]+)/g;
5631
- while ((m = sectionTagRe.exec(code)) !== null) {
5632
- const name = m[1].trim();
5633
- if (name && name.length > 1 && name.length < 40 && !seen.has(name.toLowerCase())) {
5634
- seen.add(name.toLowerCase());
5635
- sections.push({ name, order: sections.length });
5253
+ for (const pattern of PLACEHOLDER_PATTERNS) {
5254
+ const lines = code.split("\n");
5255
+ for (let i = 0; i < lines.length; i++) {
5256
+ if (pattern.test(lines[i])) {
5257
+ issues.push({
5258
+ line: i + 1,
5259
+ type: "PLACEHOLDER",
5260
+ message: "Placeholder content detected \u2014 use real contextual content",
5261
+ severity: "error"
5262
+ });
5636
5263
  }
5637
5264
  }
5638
5265
  }
5639
- return sections;
5640
- }
5641
- function extractComponentUsage(code) {
5642
- const usage = {};
5643
- const importRe = /import\s*\{([^}]+)\}\s*from\s*['"]@\/components\/ui\/[^'"]+['"]/g;
5644
- const importedComponents = [];
5645
- let m;
5646
- while ((m = importRe.exec(code)) !== null) {
5647
- const names = m[1].split(",").map((s) => s.trim()).filter(Boolean);
5648
- importedComponents.push(...names);
5649
- }
5650
- for (const comp of importedComponents) {
5651
- const re = new RegExp(`<${comp}[\\s/>]`, "g");
5652
- const matches = code.match(re);
5653
- usage[comp] = matches ? matches.length : 0;
5654
- }
5655
- return usage;
5656
- }
5657
- function extractIconCount(code) {
5658
- const m = code.match(/import\s*\{([^}]+)\}\s*from\s*['"]lucide-react['"]/);
5659
- if (!m) return 0;
5660
- return m[1].split(",").map((s) => s.trim()).filter(Boolean).length;
5661
- }
5662
- function inferLayoutPattern(code) {
5663
- const funcBodyMatch = code.match(/return\s*\(\s*(<[^]*)/s);
5664
- const topLevel = funcBodyMatch ? funcBodyMatch[1].slice(0, 500) : code.slice(0, 800);
5665
- if (/grid-cols|grid\s+md:grid-cols|grid\s+lg:grid-cols/.test(topLevel)) return "grid";
5666
- if (/sidebar|aside/.test(topLevel)) return "sidebar";
5667
- if (/max-w-\d|mx-auto|container/.test(topLevel)) return "centered";
5668
- if (/min-h-screen|min-h-svh/.test(topLevel)) return "full-width";
5669
- return "unknown";
5670
- }
5671
- function detectFormUsage(code) {
5672
- const importRe = /import\s*\{([^}]+)\}\s*from\s*['"]@\/components\/ui\/[^'"]+['"]/g;
5673
- let m;
5674
- while ((m = importRe.exec(code)) !== null) {
5675
- const names = m[1].split(",").map((s) => s.trim());
5676
- if (names.some((n) => FORM_COMPONENTS.has(n))) return true;
5677
- }
5678
- return false;
5679
- }
5680
- function summarizePageAnalysis(pageName, route, analysis) {
5681
- const parts = [`${pageName} (${route})`];
5682
- if (analysis.sections && analysis.sections.length > 0) {
5683
- parts.push(`sections: ${analysis.sections.map((s) => s.name).join(", ")}`);
5684
- }
5685
- if (analysis.componentUsage) {
5686
- const entries = Object.entries(analysis.componentUsage).filter(([, c]) => c > 0);
5687
- if (entries.length > 0) {
5688
- parts.push(`uses: ${entries.map(([n, c]) => `${n}(${c})`).join(", ")}`);
5689
- }
5690
- }
5691
- if (analysis.layoutPattern && analysis.layoutPattern !== "unknown") {
5692
- parts.push(`layout: ${analysis.layoutPattern}`);
5693
- }
5694
- if (analysis.hasForm) parts.push("has-form");
5695
- return `- ${parts.join(". ")}`;
5696
- }
5697
-
5698
- // src/utils/concurrency.ts
5699
- async function pMap(items, fn, concurrency = 3) {
5700
- const results = new Array(items.length);
5701
- let nextIndex = 0;
5702
- async function worker() {
5703
- while (nextIndex < items.length) {
5704
- const i = nextIndex++;
5705
- results[i] = await fn(items[i], i);
5706
- }
5707
- }
5708
- const workers = Array.from({ length: Math.min(concurrency, items.length) }, () => worker());
5709
- await Promise.all(workers);
5710
- return results;
5711
- }
5712
-
5713
- // src/commands/chat/split-generator.ts
5714
- function buildExistingPagesContext(config2) {
5715
- const pages = config2.pages || [];
5716
- const analyzed = pages.filter((p) => p.pageAnalysis);
5717
- if (analyzed.length === 0) return "";
5718
- const lines = analyzed.map((p) => {
5719
- return summarizePageAnalysis(p.name || p.id, p.route, p.pageAnalysis);
5720
- });
5721
- let ctx = `EXISTING PAGES CONTEXT:
5722
- ${lines.join("\n")}
5723
-
5724
- Use consistent component choices, spacing, and layout patterns across all pages. Match the style and structure of existing pages.`;
5725
- const sp = config2.stylePatterns;
5726
- if (sp && typeof sp === "object") {
5727
- const parts = [];
5728
- if (sp.card) parts.push(`Cards: ${sp.card}`);
5729
- if (sp.section) parts.push(`Sections: ${sp.section}`);
5730
- if (sp.terminal) parts.push(`Terminal blocks: ${sp.terminal}`);
5731
- if (sp.iconContainer) parts.push(`Icon containers: ${sp.iconContainer}`);
5732
- if (sp.heroHeadline) parts.push(`Hero headline: ${sp.heroHeadline}`);
5733
- if (sp.sectionTitle) parts.push(`Section title: ${sp.sectionTitle}`);
5734
- if (parts.length > 0) {
5735
- ctx += `
5736
-
5737
- PROJECT STYLE PATTERNS (from sync \u2014 match these exactly):
5738
- ${parts.join("\n")}`;
5739
- }
5266
+ const hasGrid = /\bgrid\b/.test(code);
5267
+ const hasResponsive = /\bmd:|lg:/.test(code);
5268
+ if (hasGrid && !hasResponsive) {
5269
+ issues.push({
5270
+ line: 0,
5271
+ type: "NO_RESPONSIVE",
5272
+ message: "Grid layout without responsive breakpoints (md: or lg:)",
5273
+ severity: "warning"
5274
+ });
5740
5275
  }
5741
- return ctx;
5742
- }
5743
- function extractStyleContext(pageCode) {
5744
- const unique = (arr) => [...new Set(arr)];
5745
- const cardClasses = (pageCode.match(/className="[^"]*(?:rounded|border|shadow|bg-card)[^"]*"/g) || []).map((m) => m.replace(/className="|"/g, "")).filter((c) => c.includes("rounded") || c.includes("border") || c.includes("card"));
5746
- const sectionSpacing = unique(pageCode.match(/py-\d+(?:\s+md:py-\d+)?/g) || []);
5747
- const headingStyles = unique(pageCode.match(/text-(?:\d*xl|lg)\s+font-(?:bold|semibold|medium)/g) || []);
5748
- const colorPatterns = unique(
5749
- (pageCode.match(
5750
- /(?:text|bg|border)-(?:primary|secondary|muted|accent|card|destructive|foreground|background)\S*/g
5751
- ) || []).concat(
5752
- pageCode.match(
5753
- /(?:text|bg|border)-(?:emerald|blue|violet|rose|amber|zinc|slate|gray|green|red|orange|indigo|purple|teal|cyan)\S*/g
5754
- ) || []
5276
+ issues.push(
5277
+ ...checkLines(
5278
+ code,
5279
+ IMG_WITHOUT_ALT_RE,
5280
+ "MISSING_ALT",
5281
+ '<img> without alt attribute \u2014 add descriptive alt or alt="" for decorative images',
5282
+ "error"
5283
+ )
5284
+ );
5285
+ issues.push(
5286
+ ...checkLines(
5287
+ code,
5288
+ GENERIC_BUTTON_LABELS,
5289
+ "GENERIC_BUTTON_TEXT",
5290
+ 'Generic button text \u2014 use specific verb ("Save changes", "Delete account")',
5291
+ "warning"
5755
5292
  )
5756
5293
  );
5757
- const iconPatterns = unique(pageCode.match(/(?:rounded-\S+\s+)?p-\d+(?:\.\d+)?\s*(?:bg-\S+)?/g) || []).filter(
5758
- (p) => p.includes("bg-") || p.includes("rounded")
5759
- );
5760
- const buttonPatterns = unique(
5761
- (pageCode.match(/className="[^"]*(?:hover:|active:)[^"]*"/g) || []).map((m) => m.replace(/className="|"/g, "")).filter((c) => c.includes("px-") || c.includes("py-") || c.includes("rounded"))
5294
+ const h1Matches = code.match(/<h1[\s>]/g);
5295
+ if (!h1Matches || h1Matches.length === 0) {
5296
+ issues.push({
5297
+ line: 0,
5298
+ type: "NO_H1",
5299
+ message: "Page has no <h1> \u2014 every page should have exactly one h1 heading",
5300
+ severity: "warning"
5301
+ });
5302
+ } else if (h1Matches.length > 1) {
5303
+ issues.push({
5304
+ line: 0,
5305
+ type: "MULTIPLE_H1",
5306
+ message: `Page has ${h1Matches.length} <h1> elements \u2014 use exactly one per page`,
5307
+ severity: "warning"
5308
+ });
5309
+ }
5310
+ const headingLevels = [...code.matchAll(/<h([1-6])[\s>]/g)].map((m) => parseInt(m[1]));
5311
+ for (let i = 1; i < headingLevels.length; i++) {
5312
+ if (headingLevels[i] > headingLevels[i - 1] + 1) {
5313
+ issues.push({
5314
+ line: 0,
5315
+ type: "SKIPPED_HEADING",
5316
+ message: `Heading level skipped: h${headingLevels[i - 1]} \u2192 h${headingLevels[i]} \u2014 don't skip levels`,
5317
+ severity: "warning"
5318
+ });
5319
+ break;
5320
+ }
5321
+ }
5322
+ const hasLabelImport = /import\s.*Label.*from\s+['"]@\/components\/ui\//.test(code);
5323
+ const inputCount = (code.match(INPUT_TAG_RE) || []).length;
5324
+ const labelForCount = (code.match(LABEL_FOR_RE) || []).length;
5325
+ if (hasLabelImport && inputCount > 0 && labelForCount === 0) {
5326
+ issues.push({
5327
+ line: 0,
5328
+ type: "MISSING_LABEL",
5329
+ message: "Inputs found but no Label with htmlFor \u2014 every input must have a visible label",
5330
+ severity: "error"
5331
+ });
5332
+ }
5333
+ if (!hasLabelImport && inputCount > 0 && !/<label\b/i.test(code)) {
5334
+ issues.push({
5335
+ line: 0,
5336
+ type: "MISSING_LABEL",
5337
+ message: "Inputs found but no Label component \u2014 import Label and add htmlFor on each input",
5338
+ severity: "error"
5339
+ });
5340
+ }
5341
+ const hasPlaceholder = /placeholder\s*=/.test(code);
5342
+ if (hasPlaceholder && inputCount > 0 && labelForCount === 0 && !/<label\b/i.test(code) && !/<Label\b/.test(code)) {
5343
+ issues.push({
5344
+ line: 0,
5345
+ type: "PLACEHOLDER_ONLY_LABEL",
5346
+ message: "Inputs use placeholder only \u2014 add visible Label with htmlFor (placeholder is not a substitute)",
5347
+ severity: "error"
5348
+ });
5349
+ }
5350
+ const hasInteractive = /<Button\b|<button\b|<a\b/.test(code);
5351
+ const hasFocusVisible = /focus-visible:/.test(code);
5352
+ const usesShadcnButton = /import\s.*Button.*from\s+['"]@\/components\/ui\//.test(code);
5353
+ if (hasInteractive && !hasFocusVisible && !usesShadcnButton) {
5354
+ issues.push({
5355
+ line: 0,
5356
+ type: "MISSING_FOCUS_VISIBLE",
5357
+ message: "Interactive elements without focus-visible styles \u2014 add focus-visible:ring-2 focus-visible:ring-ring",
5358
+ severity: "info"
5359
+ });
5360
+ }
5361
+ const hasTableOrList = /<Table\b|<table\b|\.map\s*\(|<ul\b|<ol\b/.test(code);
5362
+ 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(
5363
+ code
5762
5364
  );
5763
- const bgPatterns = unique(pageCode.match(/bg-(?:muted|card|background|zinc|slate|gray)\S*/g) || []);
5764
- const gapPatterns = unique(pageCode.match(/gap-\d+/g) || []);
5765
- const gridPatterns = unique(pageCode.match(/grid-cols-\d+|md:grid-cols-\d+|lg:grid-cols-\d+/g) || []);
5766
- const containerPatterns = unique(pageCode.match(/container\s+max-w-\S+|max-w-\d+xl\s+mx-auto/g) || []);
5767
- const lines = [];
5768
- if (containerPatterns.length > 0) {
5769
- lines.push(`Container (MUST match for alignment with header/footer): ${containerPatterns[0]} px-4`);
5365
+ if (hasTableOrList && !hasEmptyCheck) {
5366
+ issues.push({
5367
+ line: 0,
5368
+ type: "NO_EMPTY_STATE",
5369
+ message: "List/table/grid without empty state handling \u2014 add friendly message + primary action",
5370
+ severity: "warning"
5371
+ });
5770
5372
  }
5771
- if (cardClasses.length > 0) lines.push(`Cards: ${unique(cardClasses).slice(0, 4).join(" | ")}`);
5772
- if (sectionSpacing.length > 0) lines.push(`Section spacing: ${sectionSpacing.join(", ")}`);
5773
- if (headingStyles.length > 0) lines.push(`Headings: ${headingStyles.join(", ")}`);
5774
- if (colorPatterns.length > 0) lines.push(`Colors: ${colorPatterns.slice(0, 15).join(", ")}`);
5775
- if (iconPatterns.length > 0) lines.push(`Icon containers: ${iconPatterns.slice(0, 4).join(" | ")}`);
5776
- if (buttonPatterns.length > 0) lines.push(`Buttons: ${buttonPatterns.slice(0, 3).join(" | ")}`);
5777
- if (bgPatterns.length > 0) lines.push(`Section backgrounds: ${bgPatterns.slice(0, 6).join(", ")}`);
5778
- if (gapPatterns.length > 0) lines.push(`Gaps: ${gapPatterns.join(", ")}`);
5779
- if (gridPatterns.length > 0) lines.push(`Grids: ${gridPatterns.join(", ")}`);
5780
- if (lines.length === 0) return "";
5781
- return `STYLE CONTEXT (match these patterns exactly for visual consistency with the Home page):
5782
- ${lines.map((l) => ` - ${l}`).join("\n")}`;
5783
- }
5784
- async function splitGeneratePages(spinner, message, modCtx, provider, parseOpts) {
5785
- let pageNames = [];
5786
- spinner.start("Phase 1/4 \u2014 Planning pages...");
5787
- try {
5788
- const planResult = await parseModification(message, modCtx, provider, { ...parseOpts, planOnly: true });
5789
- const pageReqs = planResult.requests.filter((r) => r.type === "add-page");
5790
- pageNames = pageReqs.map((r) => {
5791
- const c = r.changes;
5792
- const name = c.name || c.id || "page";
5793
- const id = c.id || name.toLowerCase().replace(/\s+/g, "-");
5794
- const route = c.route || `/${id}`;
5795
- return { name, id, route };
5373
+ const hasDataFetching = /fetch\s*\(|useQuery|useSWR|useEffect\s*\([^)]*fetch|getData|loadData/i.test(code);
5374
+ const hasLoadingPattern = /skeleton|Skeleton|spinner|Spinner|isLoading|loading|Loading/.test(code);
5375
+ if (hasDataFetching && !hasLoadingPattern) {
5376
+ issues.push({
5377
+ line: 0,
5378
+ type: "NO_LOADING_STATE",
5379
+ message: "Page with data fetching but no loading/skeleton pattern \u2014 add skeleton or spinner",
5380
+ severity: "warning"
5796
5381
  });
5797
- } catch {
5798
- spinner.text = "AI plan failed \u2014 extracting pages from your request...";
5799
5382
  }
5800
- if (pageNames.length === 0) {
5801
- pageNames = extractPageNamesFromMessage(message);
5383
+ const hasGenericError = /Something went wrong|"Error"|'Error'|>Error<\//.test(code) || /error\.message\s*\|\|\s*["']Error["']/.test(code);
5384
+ if (hasGenericError) {
5385
+ issues.push({
5386
+ line: 0,
5387
+ type: "EMPTY_ERROR_MESSAGE",
5388
+ message: "Generic error message detected \u2014 use what happened + why + what to do next",
5389
+ severity: "warning"
5390
+ });
5802
5391
  }
5803
- if (pageNames.length === 0) {
5804
- spinner.fail("Could not determine pages to create");
5805
- return [];
5392
+ const hasDestructive = /variant\s*=\s*["']destructive["']|Delete|Remove/.test(code);
5393
+ const hasConfirm = /AlertDialog|Dialog.*confirm|confirm\s*\(|onConfirm|are you sure/i.test(code);
5394
+ if (hasDestructive && !hasConfirm) {
5395
+ issues.push({
5396
+ line: 0,
5397
+ type: "DESTRUCTIVE_NO_CONFIRM",
5398
+ message: "Destructive action without confirmation dialog \u2014 add confirm before execution",
5399
+ severity: "warning"
5400
+ });
5806
5401
  }
5807
- pageNames = deduplicatePages(pageNames);
5808
- const hasHomePage = pageNames.some((p) => p.route === "/");
5809
- if (!hasHomePage) {
5810
- const userPages = (modCtx.config.pages || []).filter(
5811
- (p) => p.id !== "home" && p.id !== "new" && p.route !== "/"
5812
- );
5813
- const isFreshProject = userPages.length === 0;
5814
- if (isFreshProject || impliesFullWebsite(message)) {
5815
- pageNames.unshift({ name: "Home", id: "home", route: "/" });
5816
- }
5402
+ const hasFormSubmit = /<form\b|onSubmit|type\s*=\s*["']submit["']/.test(code);
5403
+ const hasFeedback = /toast|success|error|Saved|Saving|saving|setError|setSuccess/i.test(code);
5404
+ if (hasFormSubmit && !hasFeedback) {
5405
+ issues.push({
5406
+ line: 0,
5407
+ type: "FORM_NO_FEEDBACK",
5408
+ message: 'Form with submit but no success/error feedback pattern \u2014 add "Saving..." then "Saved" or error',
5409
+ severity: "info"
5410
+ });
5817
5411
  }
5818
- const existingRoutes = new Set((modCtx.config.pages || []).map((p) => p.route).filter(Boolean));
5819
- const inferred = inferRelatedPages(pageNames).filter((p) => !existingRoutes.has(p.route));
5820
- if (inferred.length > 0) {
5821
- pageNames.push(...inferred);
5822
- pageNames = deduplicatePages(pageNames);
5412
+ const hasNav = /<nav\b|NavLink|navigation|sidebar.*link|Sidebar.*link/i.test(code);
5413
+ const hasActiveState = /pathname|active|current|aria-current|data-active/.test(code);
5414
+ if (hasNav && !hasActiveState) {
5415
+ issues.push({
5416
+ line: 0,
5417
+ type: "NAV_NO_ACTIVE_STATE",
5418
+ message: "Navigation without active/current page indicator \u2014 add active state for current route",
5419
+ severity: "info"
5420
+ });
5823
5421
  }
5824
- const allRoutes = pageNames.map((p) => p.route).join(", ");
5825
- const allPagesList = pageNames.map((p) => `${p.name} (${p.route})`).join(", ");
5826
- const inferredNote = inferred.length > 0 ? ` (${inferred.length} auto-inferred)` : "";
5827
- spinner.succeed(`Phase 1/4 \u2014 Found ${pageNames.length} pages${inferredNote}: ${allPagesList}`);
5828
- const homeIdx = pageNames.findIndex((p) => p.route === "/");
5829
- const homePage = homeIdx !== -1 ? pageNames[homeIdx] : pageNames[0];
5830
- const remainingPages = pageNames.filter((_, i) => i !== (homeIdx !== -1 ? homeIdx : 0));
5831
- spinner.start(`Phase 2/4 \u2014 Generating ${homePage.name} page (sets design direction)...`);
5832
- let homeRequest = null;
5833
- let homePageCode = "";
5834
- try {
5835
- const homeResult = await parseModification(
5836
- `Create ONE page called "${homePage.name}" at route "${homePage.route}". Context: ${message}. This REPLACES the default placeholder page \u2014 generate a complete, content-rich landing page for the project described above. Generate complete pageCode. Include a branded site-wide <header> with navigation links to ALL these pages: ${allPagesList}. Use these EXACT routes in navigation: ${allRoutes}. Include a <footer> at the bottom. Make it visually polished \u2014 this page sets the design direction for the entire site. Do not generate other pages.`,
5837
- modCtx,
5838
- provider,
5839
- parseOpts
5840
- );
5841
- const codePage = homeResult.requests.find((r) => r.type === "add-page");
5842
- if (codePage) {
5843
- homeRequest = codePage;
5844
- homePageCode = codePage.changes?.pageCode || "";
5422
+ if (validRoutes && validRoutes.length > 0) {
5423
+ const routeSet = new Set(validRoutes);
5424
+ routeSet.add("#");
5425
+ const lines = code.split("\n");
5426
+ const linkHrefRe = /href\s*=\s*["'](\/[a-z0-9/-]*)["']/gi;
5427
+ for (let i = 0; i < lines.length; i++) {
5428
+ let match;
5429
+ while ((match = linkHrefRe.exec(lines[i])) !== null) {
5430
+ const target = match[1];
5431
+ if (target === "/" || target.startsWith("/design-system") || target.startsWith("/api") || target.startsWith("/#"))
5432
+ continue;
5433
+ if (!routeSet.has(target)) {
5434
+ issues.push({
5435
+ line: i + 1,
5436
+ type: "BROKEN_INTERNAL_LINK",
5437
+ message: `Link to "${target}" \u2014 route does not exist in project`,
5438
+ severity: "warning"
5439
+ });
5440
+ }
5441
+ }
5845
5442
  }
5846
- } catch {
5847
5443
  }
5848
- if (!homeRequest) {
5849
- homeRequest = {
5850
- type: "add-page",
5851
- target: "new",
5852
- changes: { id: homePage.id, name: homePage.name, route: homePage.route }
5853
- };
5444
+ return issues;
5445
+ }
5446
+ async function autoFixCode(code) {
5447
+ const fixes = [];
5448
+ let fixed = code;
5449
+ const beforeQuoteFix = fixed;
5450
+ fixed = fixed.replace(/(:\s*'.+)\\'(\s*)$/gm, "$1'$2");
5451
+ if (fixed !== beforeQuoteFix) {
5452
+ fixes.push("fixed escaped closing quotes in strings");
5854
5453
  }
5855
- spinner.succeed(`Phase 2/4 \u2014 ${homePage.name} page generated`);
5856
- spinner.start("Phase 3/4 \u2014 Extracting design patterns...");
5857
- const styleContext = homePageCode ? extractStyleContext(homePageCode) : "";
5858
- if (styleContext) {
5859
- const lineCount = styleContext.split("\n").length - 1;
5860
- spinner.succeed(`Phase 3/4 \u2014 Extracted ${lineCount} style patterns from ${homePage.name}`);
5861
- } else {
5862
- spinner.succeed("Phase 3/4 \u2014 No style patterns extracted (Home page had no code)");
5454
+ const beforeEntityFix = fixed;
5455
+ fixed = fixed.replace(/&lt;=/g, "<=");
5456
+ fixed = fixed.replace(/&gt;=/g, ">=");
5457
+ fixed = fixed.replace(/&amp;&amp;/g, "&&");
5458
+ fixed = fixed.replace(/([\w)\]])\s*&lt;\s*([\w(])/g, "$1 < $2");
5459
+ fixed = fixed.replace(/([\w)\]])\s*&gt;\s*([\w(])/g, "$1 > $2");
5460
+ if (fixed !== beforeEntityFix) {
5461
+ fixes.push("Fixed syntax issues");
5462
+ }
5463
+ const beforeLtFix = fixed;
5464
+ fixed = fixed.replace(/>([^<{}\n]*)<(\d)/g, ">$1&lt;$2");
5465
+ fixed = fixed.replace(/>([^<{}\n]*)<([^/a-zA-Z!{>\n])/g, ">$1&lt;$2");
5466
+ if (fixed !== beforeLtFix) {
5467
+ fixes.push("escaped < in JSX text content");
5863
5468
  }
5864
- if (remainingPages.length === 0) {
5865
- return [homeRequest];
5469
+ if (/className="[^"]*\btext-base\b[^"]*"/.test(fixed)) {
5470
+ fixed = fixed.replace(/className="([^"]*)\btext-base\b([^"]*)"/g, 'className="$1text-sm$2"');
5471
+ fixes.push("text-base \u2192 text-sm");
5866
5472
  }
5867
- spinner.start(`Phase 4/4 \u2014 Generating ${remainingPages.length} pages in parallel...`);
5868
- const sharedNote = "Header and Footer are shared components rendered by the root layout. Do NOT include any site-wide <header>, <nav>, or <footer> in this page. Start with the main content directly.";
5869
- const routeNote = `EXISTING ROUTES in this project: ${allRoutes}. All internal links MUST point to one of these routes. If a target doesn't exist, use href="#".`;
5870
- const alignmentNote = 'CRITICAL LAYOUT RULE: Every <section> must wrap its content in a container div matching the header width. Use the EXACT same container classes as shown in the style context (e.g. className="container max-w-6xl px-4" or className="max-w-6xl mx-auto px-4"). Inner content can use narrower max-w for text centering, but the outer section container MUST match.';
5871
- const existingPagesContext = buildExistingPagesContext(modCtx.config);
5872
- const AI_CONCURRENCY = 3;
5873
- let phase4Done = 0;
5874
- const remainingRequests = await pMap(
5875
- remainingPages,
5876
- async ({ name, id, route }) => {
5877
- const prompt = [
5878
- `Create ONE page called "${name}" at route "${route}".`,
5879
- `Context: ${message}.`,
5880
- `Generate complete pageCode for this single page only. Do not generate other pages.`,
5881
- sharedNote,
5882
- routeNote,
5883
- alignmentNote,
5884
- existingPagesContext,
5885
- styleContext
5886
- ].filter(Boolean).join("\n\n");
5887
- try {
5888
- const result = await parseModification(prompt, modCtx, provider, parseOpts);
5889
- phase4Done++;
5890
- spinner.text = `Phase 4/4 \u2014 ${phase4Done}/${remainingPages.length} pages generated...`;
5891
- const codePage = result.requests.find((r) => r.type === "add-page");
5892
- return codePage || { type: "add-page", target: "new", changes: { id, name, route } };
5893
- } catch {
5894
- phase4Done++;
5895
- spinner.text = `Phase 4/4 \u2014 ${phase4Done}/${remainingPages.length} pages generated...`;
5896
- return { type: "add-page", target: "new", changes: { id, name, route } };
5897
- }
5898
- },
5899
- AI_CONCURRENCY
5473
+ if (/CardTitle[^>]*className="[^"]*text-(lg|xl|2xl)/.test(fixed)) {
5474
+ fixed = fixed.replace(/(CardTitle[^>]*className="[^"]*)text-(lg|xl|2xl)\b/g, "$1");
5475
+ fixes.push("large text in CardTitle \u2192 removed");
5476
+ }
5477
+ if (/className="[^"]*\bshadow-(md|lg|xl|2xl)\b[^"]*"/.test(fixed)) {
5478
+ fixed = fixed.replace(/className="([^"]*)\bshadow-(md|lg|xl|2xl)\b([^"]*)"/g, 'className="$1shadow-sm$3"');
5479
+ fixes.push("heavy shadow \u2192 shadow-sm");
5480
+ }
5481
+ const hasHooks = /\b(useState|useEffect|useRef|useCallback|useMemo|useReducer|useContext)\b/.test(fixed);
5482
+ const hasEvents = /\b(onClick|onChange|onSubmit|onBlur|onFocus|onKeyDown|onKeyUp|onMouseEnter|onMouseLeave|onScroll|onInput)\s*[={]/.test(
5483
+ fixed
5900
5484
  );
5901
- const allRequests = [homeRequest, ...remainingRequests];
5902
- const withCode = allRequests.filter((r) => r.changes?.pageCode).length;
5903
- spinner.succeed(`Phase 4/4 \u2014 Generated ${allRequests.length} pages (${withCode} with full code)`);
5904
- return allRequests;
5905
- }
5906
-
5907
- // src/commands/chat/modification-handler.ts
5908
- import { resolve as resolve7 } from "path";
5909
- import { mkdir as mkdir4 } from "fs/promises";
5910
- import { dirname as dirname6 } from "path";
5911
- import chalk11 from "chalk";
5912
- import {
5913
- getTemplateForPageType,
5914
- loadManifest as loadManifest5,
5915
- saveManifest,
5916
- updateUsedIn,
5917
- findSharedComponentByIdOrName,
5918
- generateSharedComponent as generateSharedComponent3
5919
- } from "@getcoherent/core";
5485
+ const hasUseClient = /^['"]use client['"]/.test(fixed.trim());
5486
+ if ((hasHooks || hasEvents) && !hasUseClient) {
5487
+ fixed = `'use client'
5920
5488
 
5921
- // src/utils/quality-validator.ts
5922
- var RAW_COLOR_RE = /(?:bg|text|border)-(gray|blue|red|green|yellow|purple|pink|indigo|orange|slate|zinc|stone|neutral|emerald|teal|cyan|sky|violet|fuchsia|rose|amber|lime)-\d+/g;
5923
- var HEX_IN_CLASS_RE = /className="[^"]*#[0-9a-fA-F]{3,8}[^"]*"/g;
5924
- var TEXT_BASE_RE = /\btext-base\b/g;
5925
- var HEAVY_SHADOW_RE = /\bshadow-(md|lg|xl|2xl)\b/g;
5926
- var SM_BREAKPOINT_RE = /\bsm:/g;
5927
- var XL_BREAKPOINT_RE = /\bxl:/g;
5928
- var XXL_BREAKPOINT_RE = /\b2xl:/g;
5929
- var LARGE_CARD_TITLE_RE = /CardTitle[^>]*className="[^"]*text-(lg|xl|2xl)/g;
5930
- var RAW_BUTTON_RE = /<button\b/g;
5931
- var RAW_INPUT_RE = /<input\b/g;
5932
- var RAW_SELECT_RE = /<select\b/g;
5933
- var NATIVE_CHECKBOX_RE = /<input[^>]*type\s*=\s*["']checkbox["']/g;
5934
- var NATIVE_TABLE_RE = /<table\b/g;
5935
- var PLACEHOLDER_PATTERNS = [
5936
- />\s*Lorem ipsum\b/i,
5937
- />\s*Card content\s*</i,
5938
- />\s*Your (?:text|content) here\s*</i,
5939
- />\s*Description\s*</,
5940
- />\s*Title\s*</,
5941
- /placeholder\s*text/i
5942
- ];
5943
- var GENERIC_BUTTON_LABELS = />\s*(Submit|OK|Click here|Press here|Go)\s*</i;
5944
- var IMG_WITHOUT_ALT_RE = /<img\b(?![^>]*\balt\s*=)[^>]*>/g;
5945
- var INPUT_TAG_RE = /<(?:Input|input)\b[^>]*>/g;
5946
- var LABEL_FOR_RE = /<Label\b[^>]*htmlFor\s*=/;
5947
- function isInsideCommentOrString(line, matchIndex) {
5948
- const commentIdx = line.indexOf("//");
5949
- if (commentIdx !== -1 && commentIdx < matchIndex) return true;
5950
- let inSingle = false;
5951
- let inDouble = false;
5952
- let inTemplate = false;
5953
- for (let i = 0; i < matchIndex; i++) {
5954
- const ch = line[i];
5955
- const prev = i > 0 ? line[i - 1] : "";
5956
- if (prev === "\\") continue;
5957
- if (ch === "'" && !inDouble && !inTemplate) inSingle = !inSingle;
5958
- if (ch === '"' && !inSingle && !inTemplate) inDouble = !inDouble;
5959
- if (ch === "`" && !inSingle && !inDouble) inTemplate = !inTemplate;
5489
+ ${fixed}`;
5490
+ fixes.push('added "use client" (client features detected)');
5960
5491
  }
5961
- return inSingle || inDouble || inTemplate;
5962
- }
5963
- function checkLines(code, pattern, type, message, severity, skipCommentsAndStrings = false) {
5964
- const issues = [];
5965
- const lines = code.split("\n");
5966
- let inBlockComment = false;
5492
+ if (/^['"]use client['"]/.test(fixed.trim()) && /\bexport\s+const\s+metadata\s*:\s*Metadata\s*=\s*\{/.test(fixed)) {
5493
+ const metaMatch = fixed.match(/\bexport\s+const\s+metadata\s*:\s*Metadata\s*=\s*\{/);
5494
+ if (metaMatch) {
5495
+ const start = fixed.indexOf(metaMatch[0]);
5496
+ const open = fixed.indexOf("{", start);
5497
+ let depth = 1, i = open + 1;
5498
+ while (i < fixed.length && depth > 0) {
5499
+ if (fixed[i] === "{") depth++;
5500
+ else if (fixed[i] === "}") depth--;
5501
+ i++;
5502
+ }
5503
+ const tail = fixed.slice(i);
5504
+ const semi = tail.match(/^\s*;/);
5505
+ const removeEnd = semi ? i + (semi.index + semi[0].length) : i;
5506
+ fixed = (fixed.slice(0, start) + fixed.slice(removeEnd)).replace(/\n{3,}/g, "\n\n").trim();
5507
+ fixes.push('removed metadata export (conflicts with "use client")');
5508
+ }
5509
+ }
5510
+ const lines = fixed.split("\n");
5511
+ let hasReplacedButton = false;
5967
5512
  for (let i = 0; i < lines.length; i++) {
5968
- const line = lines[i];
5969
- if (skipCommentsAndStrings) {
5970
- if (inBlockComment) {
5971
- const endIdx = line.indexOf("*/");
5972
- if (endIdx !== -1) {
5973
- inBlockComment = false;
5513
+ if (!/<button\b/.test(lines[i])) continue;
5514
+ if (lines[i].includes("aria-label")) continue;
5515
+ if (/onClick=\{.*copy/i.test(lines[i])) continue;
5516
+ const block = lines.slice(i, i + 5).join(" ");
5517
+ if (block.includes("aria-label") || /onClick=\{.*copy/i.test(block)) continue;
5518
+ lines[i] = lines[i].replace(/<button\b/g, "<Button");
5519
+ hasReplacedButton = true;
5520
+ }
5521
+ if (hasReplacedButton) {
5522
+ fixed = lines.join("\n");
5523
+ fixed = fixed.replace(/<\/button>/g, (_match, _offset) => {
5524
+ return "</Button>";
5525
+ });
5526
+ const openCount = (fixed.match(/<Button\b/g) || []).length;
5527
+ const closeCount = (fixed.match(/<\/Button>/g) || []).length;
5528
+ if (closeCount > openCount) {
5529
+ let excess = closeCount - openCount;
5530
+ fixed = fixed.replace(/<\/Button>/g, (m) => {
5531
+ if (excess > 0) {
5532
+ excess--;
5533
+ return "</button>";
5534
+ }
5535
+ return m;
5536
+ });
5537
+ }
5538
+ const hasButtonImport = /import\s.*\bButton\b.*from\s+['"]@\/components\/ui\/button['"]/.test(fixed);
5539
+ if (!hasButtonImport) {
5540
+ const lastImportIdx = fixed.lastIndexOf("\nimport ");
5541
+ if (lastImportIdx !== -1) {
5542
+ const lineEnd = fixed.indexOf("\n", lastImportIdx + 1);
5543
+ fixed = fixed.slice(0, lineEnd + 1) + "import { Button } from '@/components/ui/button'\n" + fixed.slice(lineEnd + 1);
5544
+ } else {
5545
+ const insertAfter = hasUseClient ? fixed.indexOf("\n") + 1 : 0;
5546
+ fixed = fixed.slice(0, insertAfter) + "import { Button } from '@/components/ui/button'\n" + fixed.slice(insertAfter);
5547
+ }
5548
+ }
5549
+ fixes.push("<button> \u2192 <Button> (with import)");
5550
+ }
5551
+ const colorMap = {
5552
+ "bg-zinc-950": "bg-background",
5553
+ "bg-zinc-900": "bg-background",
5554
+ "bg-slate-950": "bg-background",
5555
+ "bg-slate-900": "bg-background",
5556
+ "bg-gray-950": "bg-background",
5557
+ "bg-gray-900": "bg-background",
5558
+ "bg-zinc-800": "bg-muted",
5559
+ "bg-slate-800": "bg-muted",
5560
+ "bg-gray-800": "bg-muted",
5561
+ "bg-zinc-100": "bg-muted",
5562
+ "bg-slate-100": "bg-muted",
5563
+ "bg-gray-100": "bg-muted",
5564
+ "bg-white": "bg-background",
5565
+ "bg-black": "bg-background",
5566
+ "text-white": "text-foreground",
5567
+ "text-black": "text-foreground",
5568
+ "text-zinc-100": "text-foreground",
5569
+ "text-zinc-200": "text-foreground",
5570
+ "text-slate-100": "text-foreground",
5571
+ "text-gray-100": "text-foreground",
5572
+ "text-zinc-400": "text-muted-foreground",
5573
+ "text-zinc-500": "text-muted-foreground",
5574
+ "text-slate-400": "text-muted-foreground",
5575
+ "text-slate-500": "text-muted-foreground",
5576
+ "text-gray-400": "text-muted-foreground",
5577
+ "text-gray-500": "text-muted-foreground",
5578
+ "border-zinc-700": "border-border",
5579
+ "border-zinc-800": "border-border",
5580
+ "border-slate-700": "border-border",
5581
+ "border-gray-700": "border-border",
5582
+ "border-zinc-200": "border-border",
5583
+ "border-slate-200": "border-border",
5584
+ "border-gray-200": "border-border"
5585
+ };
5586
+ const isCodeContext = (classes) => /\bfont-mono\b/.test(classes) || /\bbg-zinc-950\b/.test(classes) || /\bbg-zinc-900\b/.test(classes);
5587
+ const isInsideTerminalBlock = (offset) => {
5588
+ const preceding = fixed.slice(Math.max(0, offset - 600), offset);
5589
+ if (!/(bg-zinc-950|bg-zinc-900)/.test(preceding)) return false;
5590
+ if (!/font-mono/.test(preceding)) return false;
5591
+ const lastClose = Math.max(preceding.lastIndexOf("</div>"), preceding.lastIndexOf("</section>"));
5592
+ const lastTerminal = Math.max(preceding.lastIndexOf("bg-zinc-950"), preceding.lastIndexOf("bg-zinc-900"));
5593
+ return lastTerminal > lastClose;
5594
+ };
5595
+ let hadColorFix = false;
5596
+ fixed = fixed.replace(/className="([^"]*)"/g, (fullMatch, classes, offset) => {
5597
+ if (isCodeContext(classes)) return fullMatch;
5598
+ if (isInsideTerminalBlock(offset)) return fullMatch;
5599
+ let result = classes;
5600
+ const accentColorRe = /\b(bg|text|border)-(emerald|blue|violet|indigo|purple|teal|cyan|sky|rose|amber|red|green|yellow|pink|orange|fuchsia|lime)-(\d+)\b/g;
5601
+ result = result.replace(accentColorRe, (m, prefix, color, shade) => {
5602
+ if (colorMap[m]) {
5603
+ hadColorFix = true;
5604
+ return colorMap[m];
5605
+ }
5606
+ const n = parseInt(shade);
5607
+ const isDestructive = color === "red";
5608
+ if (prefix === "bg") {
5609
+ if (n >= 500 && n <= 700) {
5610
+ hadColorFix = true;
5611
+ return isDestructive ? "bg-destructive" : "bg-primary";
5612
+ }
5613
+ if (n >= 100 && n <= 200) {
5614
+ hadColorFix = true;
5615
+ return isDestructive ? "bg-destructive/10" : "bg-primary/10";
5616
+ }
5617
+ if (n >= 300 && n <= 400) {
5618
+ hadColorFix = true;
5619
+ return isDestructive ? "bg-destructive/20" : "bg-primary/20";
5620
+ }
5621
+ if (n >= 800) {
5622
+ hadColorFix = true;
5623
+ return "bg-muted";
5624
+ }
5625
+ }
5626
+ if (prefix === "text") {
5627
+ if (n >= 400 && n <= 600) {
5628
+ hadColorFix = true;
5629
+ return isDestructive ? "text-destructive" : "text-primary";
5630
+ }
5631
+ if (n >= 100 && n <= 300) {
5632
+ hadColorFix = true;
5633
+ return "text-foreground";
5634
+ }
5635
+ if (n >= 700) {
5636
+ hadColorFix = true;
5637
+ return "text-foreground";
5974
5638
  }
5975
- continue;
5976
5639
  }
5977
- const blockStart = line.indexOf("/*");
5978
- if (blockStart !== -1 && !line.includes("*/")) {
5979
- inBlockComment = true;
5980
- continue;
5640
+ if (prefix === "border") {
5641
+ hadColorFix = true;
5642
+ return isDestructive ? "border-destructive" : "border-primary";
5981
5643
  }
5982
- let m;
5983
- pattern.lastIndex = 0;
5984
- while ((m = pattern.exec(line)) !== null) {
5985
- if (!isInsideCommentOrString(line, m.index)) {
5986
- issues.push({ line: i + 1, type, message, severity });
5987
- break;
5644
+ return m;
5645
+ });
5646
+ const neutralColorRe = /\b(bg|text|border)-(zinc|slate|gray|neutral|stone)-(\d+)\b/g;
5647
+ result = result.replace(neutralColorRe, (m, prefix, _color, shade) => {
5648
+ if (colorMap[m]) {
5649
+ hadColorFix = true;
5650
+ return colorMap[m];
5651
+ }
5652
+ const n = parseInt(shade);
5653
+ if (prefix === "bg") {
5654
+ if (n >= 800) {
5655
+ hadColorFix = true;
5656
+ return "bg-background";
5657
+ }
5658
+ if (n >= 100 && n <= 300) {
5659
+ hadColorFix = true;
5660
+ return "bg-muted";
5988
5661
  }
5989
5662
  }
5990
- } else {
5991
- pattern.lastIndex = 0;
5992
- if (pattern.test(line)) {
5993
- issues.push({ line: i + 1, type, message, severity });
5663
+ if (prefix === "text") {
5664
+ if (n >= 100 && n <= 300) {
5665
+ hadColorFix = true;
5666
+ return "text-foreground";
5667
+ }
5668
+ if (n >= 400 && n <= 600) {
5669
+ hadColorFix = true;
5670
+ return "text-muted-foreground";
5671
+ }
5672
+ }
5673
+ if (prefix === "border") {
5674
+ hadColorFix = true;
5675
+ return "border-border";
5676
+ }
5677
+ return m;
5678
+ });
5679
+ if (result !== classes) return `className="${result}"`;
5680
+ return fullMatch;
5681
+ });
5682
+ if (hadColorFix) fixes.push("raw colors \u2192 semantic tokens");
5683
+ const selectRe = /<select\b[^>]*>([\s\S]*?)<\/select>/g;
5684
+ let hadSelectFix = false;
5685
+ fixed = fixed.replace(selectRe, (_match, inner) => {
5686
+ const options = [];
5687
+ const optionRe = /<option\s+value="([^"]*)"[^>]*>([^<]*)<\/option>/g;
5688
+ let optMatch;
5689
+ while ((optMatch = optionRe.exec(inner)) !== null) {
5690
+ options.push({ value: optMatch[1], label: optMatch[2] });
5691
+ }
5692
+ if (options.length === 0) return _match;
5693
+ hadSelectFix = true;
5694
+ const items = options.map((o) => ` <SelectItem value="${o.value}">${o.label}</SelectItem>`).join("\n");
5695
+ return `<Select>
5696
+ <SelectTrigger>
5697
+ <SelectValue placeholder="Select..." />
5698
+ </SelectTrigger>
5699
+ <SelectContent>
5700
+ ${items}
5701
+ </SelectContent>
5702
+ </Select>`;
5703
+ });
5704
+ if (hadSelectFix) {
5705
+ fixes.push("<select> \u2192 shadcn Select");
5706
+ const selectImport = `import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select'`;
5707
+ if (!/from\s+['"]@\/components\/ui\/select['"]/.test(fixed)) {
5708
+ const replaced = fixed.replace(
5709
+ /(import\s+\{[^}]*\}\s+from\s+['"]@\/components\/ui\/[^'"]+['"])/,
5710
+ `$1
5711
+ ${selectImport}`
5712
+ );
5713
+ if (replaced !== fixed) {
5714
+ fixed = replaced;
5715
+ } else {
5716
+ fixed = selectImport + "\n" + fixed;
5994
5717
  }
5995
5718
  }
5996
5719
  }
5997
- return issues;
5998
- }
5999
- function validatePageQuality(code, validRoutes) {
6000
- const issues = [];
6001
- const allLines = code.split("\n");
6002
- const isTerminalContext = (lineNum) => {
6003
- const start = Math.max(0, lineNum - 20);
6004
- const nearby = allLines.slice(start, lineNum).join(" ");
6005
- if (/font-mono/.test(allLines[lineNum - 1] || "")) return true;
6006
- if (/bg-zinc-950|bg-zinc-900/.test(nearby) && /font-mono/.test(nearby)) return true;
6007
- return false;
6008
- };
6009
- issues.push(
6010
- ...checkLines(
6011
- code,
6012
- RAW_COLOR_RE,
6013
- "RAW_COLOR",
6014
- "Raw Tailwind color detected \u2014 use semantic tokens (bg-primary, text-muted-foreground, etc.)",
6015
- "error"
6016
- ).filter((issue) => !isTerminalContext(issue.line))
6017
- );
6018
- issues.push(
6019
- ...checkLines(
6020
- code,
6021
- HEX_IN_CLASS_RE,
6022
- "HEX_IN_CLASS",
6023
- "Hex color in className \u2014 use CSS variables via semantic tokens",
6024
- "error"
6025
- )
6026
- );
6027
- issues.push(
6028
- ...checkLines(code, TEXT_BASE_RE, "TEXT_BASE", "text-base detected \u2014 use text-sm as base font size", "warning")
6029
- );
6030
- issues.push(
6031
- ...checkLines(code, HEAVY_SHADOW_RE, "HEAVY_SHADOW", "Heavy shadow detected \u2014 use shadow-sm or none", "warning")
6032
- );
6033
- issues.push(
6034
- ...checkLines(
6035
- code,
6036
- SM_BREAKPOINT_RE,
6037
- "SM_BREAKPOINT",
6038
- "sm: breakpoint \u2014 consider if md:/lg: is sufficient",
6039
- "info"
6040
- )
6041
- );
6042
- issues.push(
6043
- ...checkLines(
6044
- code,
6045
- XL_BREAKPOINT_RE,
6046
- "XL_BREAKPOINT",
6047
- "xl: breakpoint \u2014 consider if md:/lg: is sufficient",
6048
- "info"
6049
- )
6050
- );
6051
- issues.push(
6052
- ...checkLines(
6053
- code,
6054
- XXL_BREAKPOINT_RE,
6055
- "XXL_BREAKPOINT",
6056
- "2xl: breakpoint \u2014 rarely needed, consider xl: instead",
6057
- "warning"
6058
- )
6059
- );
6060
- issues.push(
6061
- ...checkLines(
6062
- code,
6063
- LARGE_CARD_TITLE_RE,
6064
- "LARGE_CARD_TITLE",
6065
- "Large text on CardTitle \u2014 use text-sm font-medium",
6066
- "warning"
6067
- )
6068
- );
6069
- const codeLines = code.split("\n");
6070
- issues.push(
6071
- ...checkLines(
6072
- code,
6073
- RAW_BUTTON_RE,
6074
- "NATIVE_BUTTON",
6075
- "Native <button> \u2014 use Button from @/components/ui/button",
6076
- "error",
6077
- true
6078
- ).filter((issue) => {
6079
- const nearby = codeLines.slice(Math.max(0, issue.line - 1), issue.line + 5).join(" ");
6080
- if (nearby.includes("aria-label")) return false;
6081
- if (/onClick=\{.*copy/i.test(nearby)) return false;
6082
- return true;
6083
- })
6084
- );
6085
- issues.push(
6086
- ...checkLines(
6087
- code,
6088
- RAW_SELECT_RE,
6089
- "NATIVE_SELECT",
6090
- "Native <select> \u2014 use Select from @/components/ui/select",
6091
- "error",
6092
- true
6093
- )
6094
- );
6095
- issues.push(
6096
- ...checkLines(
6097
- code,
6098
- NATIVE_CHECKBOX_RE,
6099
- "NATIVE_CHECKBOX",
6100
- 'Native <input type="checkbox"> \u2014 use Switch or Checkbox from @/components/ui/switch or @/components/ui/checkbox',
6101
- "error",
6102
- true
6103
- )
6104
- );
6105
- issues.push(
6106
- ...checkLines(
6107
- code,
6108
- NATIVE_TABLE_RE,
6109
- "NATIVE_TABLE",
6110
- "Native <table> \u2014 use Table, TableHeader, TableBody, etc. from @/components/ui/table",
6111
- "warning",
6112
- true
6113
- )
6114
- );
6115
- const hasInputImport = /import\s.*Input.*from\s+['"]@\/components\/ui\//.test(code);
6116
- if (!hasInputImport) {
6117
- issues.push(
6118
- ...checkLines(
6119
- code,
6120
- RAW_INPUT_RE,
6121
- "RAW_INPUT",
6122
- "Raw <input> element \u2014 import and use Input from @/components/ui/input",
6123
- "warning",
6124
- true
6125
- )
6126
- );
5720
+ const lucideImportMatch = fixed.match(/import\s*\{([^}]+)\}\s*from\s*["']lucide-react["']/);
5721
+ if (lucideImportMatch) {
5722
+ let lucideExports = null;
5723
+ try {
5724
+ const { createRequire } = await import("module");
5725
+ const require2 = createRequire(process.cwd() + "/package.json");
5726
+ const lr = require2("lucide-react");
5727
+ lucideExports = new Set(Object.keys(lr).filter((k) => /^[A-Z]/.test(k)));
5728
+ } catch {
5729
+ }
5730
+ if (lucideExports) {
5731
+ const nonLucideImports = /* @__PURE__ */ new Set();
5732
+ for (const m of fixed.matchAll(/import\s*\{([^}]+)\}\s*from\s*["'](?!lucide-react)([^"']+)["']/g)) {
5733
+ m[1].split(",").map((s) => s.trim()).filter(Boolean).forEach((n) => nonLucideImports.add(n));
5734
+ }
5735
+ const iconNames = lucideImportMatch[1].split(",").map((s) => s.trim()).filter(Boolean);
5736
+ const duplicates = iconNames.filter((name) => nonLucideImports.has(name));
5737
+ let newImport = lucideImportMatch[1];
5738
+ for (const dup of duplicates) {
5739
+ newImport = newImport.replace(new RegExp(`\\b${dup}\\b,?\\s*`), "");
5740
+ fixes.push(`removed ${dup} from lucide import (conflicts with UI component import)`);
5741
+ }
5742
+ const invalid = iconNames.filter((name) => !lucideExports.has(name) && !nonLucideImports.has(name));
5743
+ if (invalid.length > 0) {
5744
+ const fallback = "Circle";
5745
+ for (const bad of invalid) {
5746
+ const re = new RegExp(`\\b${bad}\\b`, "g");
5747
+ newImport = newImport.replace(re, fallback);
5748
+ fixed = fixed.replace(re, fallback);
5749
+ }
5750
+ fixes.push(`invalid lucide icons \u2192 ${fallback}: ${invalid.join(", ")}`);
5751
+ }
5752
+ if (duplicates.length > 0 || invalid.length > 0) {
5753
+ const importedNames = [
5754
+ ...new Set(
5755
+ newImport.split(",").map((s) => s.trim()).filter(Boolean)
5756
+ )
5757
+ ];
5758
+ const originalImportLine = lucideImportMatch[0];
5759
+ fixed = fixed.replace(originalImportLine, `import { ${importedNames.join(", ")} } from "lucide-react"`);
5760
+ }
5761
+ }
6127
5762
  }
6128
- for (const pattern of PLACEHOLDER_PATTERNS) {
6129
- const lines = code.split("\n");
6130
- for (let i = 0; i < lines.length; i++) {
6131
- if (pattern.test(lines[i])) {
6132
- issues.push({
6133
- line: i + 1,
6134
- type: "PLACEHOLDER",
6135
- message: "Placeholder content detected \u2014 use real contextual content",
6136
- severity: "error"
6137
- });
5763
+ const lucideImportMatch2 = fixed.match(/import\s*\{([^}]+)\}\s*from\s*["']lucide-react["']/);
5764
+ if (lucideImportMatch2) {
5765
+ let lucideExports2 = null;
5766
+ try {
5767
+ const { createRequire } = await import("module");
5768
+ const req = createRequire(process.cwd() + "/package.json");
5769
+ const lr = req("lucide-react");
5770
+ lucideExports2 = new Set(Object.keys(lr).filter((k) => /^[A-Z]/.test(k)));
5771
+ } catch {
5772
+ }
5773
+ if (lucideExports2) {
5774
+ const allImportedNames = /* @__PURE__ */ new Set();
5775
+ for (const m of fixed.matchAll(/import\s*\{([^}]+)\}\s*from/g)) {
5776
+ m[1].split(",").map((s) => s.trim()).filter(Boolean).forEach((n) => allImportedNames.add(n));
5777
+ }
5778
+ for (const m of fixed.matchAll(/import\s+([A-Z]\w+)\s+from/g)) {
5779
+ allImportedNames.add(m[1]);
5780
+ }
5781
+ const lucideImported = new Set(
5782
+ lucideImportMatch2[1].split(",").map((s) => s.trim()).filter(Boolean)
5783
+ );
5784
+ const jsxIconRefs = [...new Set([...fixed.matchAll(/<([A-Z][a-zA-Z]*Icon)\s/g)].map((m) => m[1]))];
5785
+ const missing = [];
5786
+ for (const ref of jsxIconRefs) {
5787
+ if (allImportedNames.has(ref)) continue;
5788
+ if (fixed.includes(`function ${ref}`) || fixed.includes(`const ${ref}`)) continue;
5789
+ const baseName = ref.replace(/Icon$/, "");
5790
+ if (lucideExports2.has(ref)) {
5791
+ missing.push(ref);
5792
+ lucideImported.add(ref);
5793
+ } else if (lucideExports2.has(baseName)) {
5794
+ const re = new RegExp(`\\b${ref}\\b`, "g");
5795
+ fixed = fixed.replace(re, baseName);
5796
+ missing.push(baseName);
5797
+ lucideImported.add(baseName);
5798
+ fixes.push(`renamed ${ref} \u2192 ${baseName} (lucide-react)`);
5799
+ } else {
5800
+ const fallback = "Circle";
5801
+ const re = new RegExp(`\\b${ref}\\b`, "g");
5802
+ fixed = fixed.replace(re, fallback);
5803
+ lucideImported.add(fallback);
5804
+ fixes.push(`unknown icon ${ref} \u2192 ${fallback}`);
5805
+ }
5806
+ }
5807
+ if (missing.length > 0) {
5808
+ const allNames = [...lucideImported];
5809
+ const origLine = lucideImportMatch2[0];
5810
+ fixed = fixed.replace(origLine, `import { ${allNames.join(", ")} } from "lucide-react"`);
5811
+ fixes.push(`added missing lucide imports: ${missing.join(", ")}`);
6138
5812
  }
6139
5813
  }
6140
5814
  }
6141
- const hasGrid = /\bgrid\b/.test(code);
6142
- const hasResponsive = /\bmd:|lg:/.test(code);
6143
- if (hasGrid && !hasResponsive) {
6144
- issues.push({
6145
- line: 0,
6146
- type: "NO_RESPONSIVE",
6147
- message: "Grid layout without responsive breakpoints (md: or lg:)",
6148
- severity: "warning"
5815
+ fixed = fixed.replace(/className="([^"]*)"/g, (_match, inner) => {
5816
+ const cleaned = inner.replace(/\s{2,}/g, " ").trim();
5817
+ return `className="${cleaned}"`;
5818
+ });
5819
+ let imgCounter = 1;
5820
+ const beforeImgFix = fixed;
5821
+ fixed = fixed.replace(/["']\/api\/placeholder\/(\d+)\/(\d+)["']/g, (_m, w, h) => {
5822
+ return `"https://picsum.photos/${w}/${h}?random=${imgCounter++}"`;
5823
+ });
5824
+ fixed = fixed.replace(/["']\/placeholder-avatar[^"']*["']/g, () => {
5825
+ return `"https://i.pravatar.cc/150?u=user${imgCounter++}"`;
5826
+ });
5827
+ fixed = fixed.replace(/["']https?:\/\/via\.placeholder\.com\/(\d+)x?(\d*)(?:\/[^"']*)?\/?["']/g, (_m, w, h) => {
5828
+ const height = h || w;
5829
+ return `"https://picsum.photos/${w}/${height}?random=${imgCounter++}"`;
5830
+ });
5831
+ fixed = fixed.replace(/["']\/images\/[^"']+\.(?:jpg|jpeg|png|webp|gif)["']/g, () => {
5832
+ return `"https://picsum.photos/800/400?random=${imgCounter++}"`;
5833
+ });
5834
+ fixed = fixed.replace(/["']\/placeholder[^"']*\.(?:jpg|jpeg|png|webp)["']/g, () => {
5835
+ return `"https://picsum.photos/800/400?random=${imgCounter++}"`;
5836
+ });
5837
+ if (fixed !== beforeImgFix) {
5838
+ fixes.push("placeholder images \u2192 working URLs (picsum/pravatar)");
5839
+ }
5840
+ return { code: fixed, fixes };
5841
+ }
5842
+ function formatIssues(issues) {
5843
+ if (issues.length === 0) return "";
5844
+ const errors = issues.filter((i) => i.severity === "error");
5845
+ const warnings = issues.filter((i) => i.severity === "warning");
5846
+ const infos = issues.filter((i) => i.severity === "info");
5847
+ const lines = [];
5848
+ if (errors.length > 0) {
5849
+ lines.push(` \u274C ${errors.length} error(s):`);
5850
+ for (const e of errors) {
5851
+ lines.push(` L${e.line}: [${e.type}] ${e.message}`);
5852
+ }
5853
+ }
5854
+ if (warnings.length > 0) {
5855
+ lines.push(` \u26A0\uFE0F ${warnings.length} warning(s):`);
5856
+ for (const w of warnings) {
5857
+ lines.push(` L${w.line}: [${w.type}] ${w.message}`);
5858
+ }
5859
+ }
5860
+ if (infos.length > 0) {
5861
+ lines.push(` \u2139\uFE0F ${infos.length} info:`);
5862
+ for (const i of infos) {
5863
+ lines.push(` L${i.line}: [${i.type}] ${i.message}`);
5864
+ }
5865
+ }
5866
+ return lines.join("\n");
5867
+ }
5868
+ function checkDesignConsistency(code) {
5869
+ const warnings = [];
5870
+ const hexPattern = /\[#[0-9a-fA-F]{3,8}\]/g;
5871
+ for (const match of code.matchAll(hexPattern)) {
5872
+ warnings.push({
5873
+ type: "hardcoded-color",
5874
+ message: `Hardcoded color ${match[0]} \u2014 use a design token (e.g., bg-primary) instead`
6149
5875
  });
6150
5876
  }
6151
- issues.push(
6152
- ...checkLines(
6153
- code,
6154
- IMG_WITHOUT_ALT_RE,
6155
- "MISSING_ALT",
6156
- '<img> without alt attribute \u2014 add descriptive alt or alt="" for decorative images',
6157
- "error"
6158
- )
6159
- );
6160
- issues.push(
6161
- ...checkLines(
6162
- code,
6163
- GENERIC_BUTTON_LABELS,
6164
- "GENERIC_BUTTON_TEXT",
6165
- 'Generic button text \u2014 use specific verb ("Save changes", "Delete account")',
6166
- "warning"
6167
- )
6168
- );
6169
- const h1Matches = code.match(/<h1[\s>]/g);
6170
- if (!h1Matches || h1Matches.length === 0) {
5877
+ const spacingPattern = /[pm][trblxy]?-\[\d+px\]/g;
5878
+ for (const match of code.matchAll(spacingPattern)) {
5879
+ warnings.push({
5880
+ type: "arbitrary-spacing",
5881
+ message: `Arbitrary spacing ${match[0]} \u2014 use Tailwind spacing scale instead`
5882
+ });
5883
+ }
5884
+ return warnings;
5885
+ }
5886
+ function verifyIncrementalEdit(before, after) {
5887
+ const issues = [];
5888
+ const hookPattern = /\buse[A-Z]\w+\s*\(/;
5889
+ if (hookPattern.test(after) && !after.includes("'use client'") && !after.includes('"use client"')) {
6171
5890
  issues.push({
6172
- line: 0,
6173
- type: "NO_H1",
6174
- message: "Page has no <h1> \u2014 every page should have exactly one h1 heading",
6175
- severity: "warning"
5891
+ type: "missing-use-client",
5892
+ message: 'Code uses React hooks but missing "use client" directive'
6176
5893
  });
6177
- } else if (h1Matches.length > 1) {
5894
+ }
5895
+ if (!after.includes("export default")) {
6178
5896
  issues.push({
6179
- line: 0,
6180
- type: "MULTIPLE_H1",
6181
- message: `Page has ${h1Matches.length} <h1> elements \u2014 use exactly one per page`,
6182
- severity: "warning"
5897
+ type: "missing-default-export",
5898
+ message: "Missing default export \u2014 page component must have a default export"
6183
5899
  });
6184
5900
  }
6185
- const headingLevels = [...code.matchAll(/<h([1-6])[\s>]/g)].map((m) => parseInt(m[1]));
6186
- for (let i = 1; i < headingLevels.length; i++) {
6187
- if (headingLevels[i] > headingLevels[i - 1] + 1) {
6188
- issues.push({
6189
- line: 0,
6190
- type: "SKIPPED_HEADING",
6191
- message: `Heading level skipped: h${headingLevels[i - 1]} \u2192 h${headingLevels[i]} \u2014 don't skip levels`,
6192
- severity: "warning"
6193
- });
6194
- break;
5901
+ const importRegex = /import\s+\{([^}]+)\}\s+from/g;
5902
+ const beforeImports = /* @__PURE__ */ new Set();
5903
+ const afterImports = /* @__PURE__ */ new Set();
5904
+ for (const match of before.matchAll(importRegex)) {
5905
+ match[1].split(",").forEach((s) => beforeImports.add(s.trim()));
5906
+ }
5907
+ for (const match of after.matchAll(importRegex)) {
5908
+ match[1].split(",").forEach((s) => afterImports.add(s.trim()));
5909
+ }
5910
+ for (const symbol of beforeImports) {
5911
+ if (!afterImports.has(symbol) && symbol.length > 0) {
5912
+ const codeWithoutImports = after.replace(/^import\s+.*$/gm, "");
5913
+ const symbolRegex = new RegExp(`\\b${symbol}\\b`);
5914
+ if (symbolRegex.test(codeWithoutImports)) {
5915
+ issues.push({
5916
+ type: "missing-import",
5917
+ symbol,
5918
+ message: `Import for "${symbol}" was removed but symbol is still used in code`
5919
+ });
5920
+ }
6195
5921
  }
6196
5922
  }
6197
- const hasLabelImport = /import\s.*Label.*from\s+['"]@\/components\/ui\//.test(code);
6198
- const inputCount = (code.match(INPUT_TAG_RE) || []).length;
6199
- const labelForCount = (code.match(LABEL_FOR_RE) || []).length;
6200
- if (hasLabelImport && inputCount > 0 && labelForCount === 0) {
6201
- issues.push({
6202
- line: 0,
6203
- type: "MISSING_LABEL",
6204
- message: "Inputs found but no Label with htmlFor \u2014 every input must have a visible label",
6205
- severity: "error"
6206
- });
5923
+ return issues;
5924
+ }
5925
+
5926
+ // src/commands/chat/utils.ts
5927
+ import { resolve as resolve5 } from "path";
5928
+ import { existsSync as existsSync13, readFileSync as readFileSync8 } from "fs";
5929
+ import { DesignSystemManager as DesignSystemManager3, loadManifest as loadManifest4 } from "@getcoherent/core";
5930
+ import chalk8 from "chalk";
5931
+ var MARKETING_ROUTES = /* @__PURE__ */ new Set(["", "landing", "pricing", "about", "contact", "blog", "features"]);
5932
+ function isMarketingRoute(route) {
5933
+ const slug = route.replace(/^\//, "").split("/")[0] || "";
5934
+ return MARKETING_ROUTES.has(slug);
5935
+ }
5936
+ function routeToFsPath(projectRoot, route, isAuth) {
5937
+ const slug = route.replace(/^\//, "");
5938
+ if (isAuth) {
5939
+ return resolve5(projectRoot, "app", "(auth)", slug || "login", "page.tsx");
6207
5940
  }
6208
- if (!hasLabelImport && inputCount > 0 && !/<label\b/i.test(code)) {
6209
- issues.push({
6210
- line: 0,
6211
- type: "MISSING_LABEL",
6212
- message: "Inputs found but no Label component \u2014 import Label and add htmlFor on each input",
6213
- severity: "error"
6214
- });
5941
+ if (!slug) {
5942
+ return resolve5(projectRoot, "app", "page.tsx");
5943
+ }
5944
+ if (isMarketingRoute(route)) {
5945
+ return resolve5(projectRoot, "app", slug, "page.tsx");
5946
+ }
5947
+ return resolve5(projectRoot, "app", "(app)", slug, "page.tsx");
5948
+ }
5949
+ function routeToRelPath(route, isAuth) {
5950
+ const slug = route.replace(/^\//, "");
5951
+ if (isAuth) {
5952
+ return `app/(auth)/${slug || "login"}/page.tsx`;
5953
+ }
5954
+ if (!slug) {
5955
+ return "app/page.tsx";
5956
+ }
5957
+ if (isMarketingRoute(route)) {
5958
+ return `app/${slug}/page.tsx`;
5959
+ }
5960
+ return `app/(app)/${slug}/page.tsx`;
5961
+ }
5962
+ function deduplicatePages(pages) {
5963
+ const normalize = (route) => route.replace(/\/$/, "").replace(/s$/, "").replace(/ue$/, "");
5964
+ const seen = /* @__PURE__ */ new Map();
5965
+ return pages.filter((page, idx) => {
5966
+ const norm = normalize(page.route);
5967
+ if (seen.has(norm)) return false;
5968
+ seen.set(norm, idx);
5969
+ return true;
5970
+ });
5971
+ }
5972
+ function extractComponentIdsFromCode(code) {
5973
+ const ids = /* @__PURE__ */ new Set();
5974
+ const allMatches = code.matchAll(/@\/components\/((?:ui\/)?[a-z0-9-]+)/g);
5975
+ for (const m of allMatches) {
5976
+ if (!m[1]) continue;
5977
+ let id = m[1];
5978
+ if (id.startsWith("ui/")) id = id.slice(3);
5979
+ if (id === "shared" || id.startsWith("shared/")) continue;
5980
+ if (id) ids.add(id);
6215
5981
  }
6216
- const hasPlaceholder = /placeholder\s*=/.test(code);
6217
- if (hasPlaceholder && inputCount > 0 && labelForCount === 0 && !/<label\b/i.test(code) && !/<Label\b/.test(code)) {
6218
- issues.push({
6219
- line: 0,
6220
- type: "PLACEHOLDER_ONLY_LABEL",
6221
- message: "Inputs use placeholder only \u2014 add visible Label with htmlFor (placeholder is not a substitute)",
6222
- severity: "error"
6223
- });
5982
+ return ids;
5983
+ }
5984
+ async function warnInlineDuplicates(projectRoot, pageName, pageCode, manifest) {
5985
+ const sectionOrWidget = manifest.shared.filter((e) => e.type === "section" || e.type === "widget");
5986
+ if (sectionOrWidget.length === 0) return;
5987
+ for (const e of sectionOrWidget) {
5988
+ const kebab = e.file.replace(/^components\/shared\//, "").replace(/\.tsx$/, "");
5989
+ const hasImport = pageCode.includes(`@/components/shared/${kebab}`);
5990
+ if (hasImport) continue;
5991
+ const sameNameAsTag = new RegExp(`<\\/?${e.name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[\\s>]`).test(pageCode);
5992
+ if (sameNameAsTag) {
5993
+ console.log(
5994
+ chalk8.yellow(
5995
+ `
5996
+ \u26A0 Page "${pageName}" contains inline code similar to ${e.id} (${e.name}). Consider using the shared component instead.`
5997
+ )
5998
+ );
5999
+ continue;
6000
+ }
6001
+ try {
6002
+ const fullPath = resolve5(projectRoot, e.file);
6003
+ const sharedSnippet = (await readFile(fullPath)).slice(0, 600);
6004
+ const sharedTokens = new Set(sharedSnippet.match(/\b[a-zA-Z0-9-]{4,}\b/g) ?? []);
6005
+ const pageTokens = pageCode.match(/\b[a-zA-Z0-9-]+\b/g) ?? [];
6006
+ let overlap = 0;
6007
+ for (const t of sharedTokens) {
6008
+ if (pageTokens.includes(t)) overlap++;
6009
+ }
6010
+ if (overlap >= 12 && sharedTokens.size >= 10) {
6011
+ console.log(
6012
+ chalk8.yellow(
6013
+ `
6014
+ \u26A0 Page "${pageName}" contains inline code similar to ${e.id} (${e.name}). Consider using the shared component instead.`
6015
+ )
6016
+ );
6017
+ }
6018
+ } catch {
6019
+ }
6224
6020
  }
6225
- const hasInteractive = /<Button\b|<button\b|<a\b/.test(code);
6226
- const hasFocusVisible = /focus-visible:/.test(code);
6227
- const usesShadcnButton = /import\s.*Button.*from\s+['"]@\/components\/ui\//.test(code);
6228
- if (hasInteractive && !hasFocusVisible && !usesShadcnButton) {
6229
- issues.push({
6230
- line: 0,
6231
- type: "MISSING_FOCUS_VISIBLE",
6232
- message: "Interactive elements without focus-visible styles \u2014 add focus-visible:ring-2 focus-visible:ring-ring",
6233
- severity: "info"
6234
- });
6021
+ }
6022
+ async function loadConfig(configPath) {
6023
+ if (!existsSync13(configPath)) {
6024
+ throw new Error(
6025
+ `Design system config not found at ${configPath}
6026
+ Run "coherent init" first to create a project.`
6027
+ );
6235
6028
  }
6236
- const hasTableOrList = /<Table\b|<table\b|\.map\s*\(|<ul\b|<ol\b/.test(code);
6237
- 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(
6238
- code
6239
- );
6240
- if (hasTableOrList && !hasEmptyCheck) {
6241
- issues.push({
6242
- line: 0,
6243
- type: "NO_EMPTY_STATE",
6244
- message: "List/table/grid without empty state handling \u2014 add friendly message + primary action",
6245
- severity: "warning"
6246
- });
6029
+ const manager = new DesignSystemManager3(configPath);
6030
+ await manager.load();
6031
+ return manager.getConfig();
6032
+ }
6033
+ function requireProject() {
6034
+ const project = findConfig();
6035
+ if (!project) {
6036
+ exitNotCoherent();
6247
6037
  }
6248
- const hasDataFetching = /fetch\s*\(|useQuery|useSWR|useEffect\s*\([^)]*fetch|getData|loadData/i.test(code);
6249
- const hasLoadingPattern = /skeleton|Skeleton|spinner|Spinner|isLoading|loading|Loading/.test(code);
6250
- if (hasDataFetching && !hasLoadingPattern) {
6251
- issues.push({
6252
- line: 0,
6253
- type: "NO_LOADING_STATE",
6254
- message: "Page with data fetching but no loading/skeleton pattern \u2014 add skeleton or spinner",
6255
- severity: "warning"
6256
- });
6038
+ warnIfVolatile(project.root);
6039
+ return project;
6040
+ }
6041
+ async function resolveTargetFlags(message, options, config2, projectRoot) {
6042
+ if (options.component) {
6043
+ const manifest = await loadManifest4(projectRoot);
6044
+ const target = options.component;
6045
+ const entry = manifest.shared.find(
6046
+ (s) => s.name.toLowerCase() === target.toLowerCase() || s.id.toLowerCase() === target.toLowerCase()
6047
+ );
6048
+ if (entry) {
6049
+ const filePath = resolve5(projectRoot, entry.file);
6050
+ let currentCode = "";
6051
+ if (existsSync13(filePath)) {
6052
+ currentCode = readFileSync8(filePath, "utf-8");
6053
+ }
6054
+ const codeSnippet = currentCode ? `
6055
+
6056
+ Current code of ${entry.name}:
6057
+ \`\`\`tsx
6058
+ ${currentCode}
6059
+ \`\`\`` : "";
6060
+ return `Modify the shared component ${entry.name} (${entry.id}, file: ${entry.file}): ${message}. Read the current code below and apply the requested changes. Return the full updated component code as pageCode.${codeSnippet}`;
6061
+ }
6062
+ console.log(chalk8.yellow(`
6063
+ \u26A0\uFE0F Component "${target}" not found in shared components.`));
6064
+ console.log(chalk8.dim(" Available: " + manifest.shared.map((s) => `${s.id} ${s.name}`).join(", ")));
6065
+ console.log(chalk8.dim(" Proceeding with message as-is...\n"));
6257
6066
  }
6258
- const hasGenericError = /Something went wrong|"Error"|'Error'|>Error<\//.test(code) || /error\.message\s*\|\|\s*["']Error["']/.test(code);
6259
- if (hasGenericError) {
6260
- issues.push({
6261
- line: 0,
6262
- type: "EMPTY_ERROR_MESSAGE",
6263
- message: "Generic error message detected \u2014 use what happened + why + what to do next",
6264
- severity: "warning"
6265
- });
6067
+ if (options.page) {
6068
+ const target = options.page;
6069
+ const page = config2.pages.find(
6070
+ (p) => p.name.toLowerCase() === target.toLowerCase() || p.id.toLowerCase() === target.toLowerCase() || p.route === target || p.route === "/" + target
6071
+ );
6072
+ if (page) {
6073
+ const relPath = page.route === "/" ? "app/page.tsx" : `app${page.route}/page.tsx`;
6074
+ const filePath = resolve5(projectRoot, relPath);
6075
+ let currentCode = "";
6076
+ if (existsSync13(filePath)) {
6077
+ currentCode = readFileSync8(filePath, "utf-8");
6078
+ }
6079
+ const codeSnippet = currentCode ? `
6080
+
6081
+ Current code of ${page.name} page:
6082
+ \`\`\`tsx
6083
+ ${currentCode}
6084
+ \`\`\`` : "";
6085
+ return `Update page "${page.name}" (id: ${page.id}, route: ${page.route}, file: ${relPath}): ${message}. Read the current code below and apply the requested changes.${codeSnippet}`;
6086
+ }
6087
+ console.log(chalk8.yellow(`
6088
+ \u26A0\uFE0F Page "${target}" not found.`));
6089
+ console.log(chalk8.dim(" Available: " + config2.pages.map((p) => `${p.id} (${p.route})`).join(", ")));
6090
+ console.log(chalk8.dim(" Proceeding with message as-is...\n"));
6266
6091
  }
6267
- const hasDestructive = /variant\s*=\s*["']destructive["']|Delete|Remove/.test(code);
6268
- const hasConfirm = /AlertDialog|Dialog.*confirm|confirm\s*\(|onConfirm|are you sure/i.test(code);
6269
- if (hasDestructive && !hasConfirm) {
6270
- issues.push({
6271
- line: 0,
6272
- type: "DESTRUCTIVE_NO_CONFIRM",
6273
- message: "Destructive action without confirmation dialog \u2014 add confirm before execution",
6274
- severity: "warning"
6275
- });
6092
+ if (options.token) {
6093
+ const target = options.token;
6094
+ return `Change design token "${target}": ${message}. Update the token value in design-system.config.ts and ensure globals.css reflects the change.`;
6276
6095
  }
6277
- const hasFormSubmit = /<form\b|onSubmit|type\s*=\s*["']submit["']/.test(code);
6278
- const hasFeedback = /toast|success|error|Saved|Saving|saving|setError|setSuccess/i.test(code);
6279
- if (hasFormSubmit && !hasFeedback) {
6280
- issues.push({
6281
- line: 0,
6282
- type: "FORM_NO_FEEDBACK",
6283
- message: 'Form with submit but no success/error feedback pattern \u2014 add "Saving..." then "Saved" or error',
6284
- severity: "info"
6285
- });
6096
+ return message;
6097
+ }
6098
+
6099
+ // src/commands/chat/request-parser.ts
6100
+ var AUTH_FLOW_PATTERNS = {
6101
+ "/login": ["/register", "/forgot-password"],
6102
+ "/signin": ["/register", "/forgot-password"],
6103
+ "/signup": ["/login"],
6104
+ "/register": ["/login"],
6105
+ "/forgot-password": ["/login", "/reset-password"],
6106
+ "/reset-password": ["/login"]
6107
+ };
6108
+ var PAGE_RELATIONSHIP_RULES = [
6109
+ {
6110
+ trigger: /\/(products|catalog|marketplace|listings|shop|store)\b/i,
6111
+ related: [{ id: "product-detail", name: "Product Detail", route: "/products/[id]" }]
6112
+ },
6113
+ {
6114
+ trigger: /\/(blog|news|articles|posts)\b/i,
6115
+ related: [{ id: "article-detail", name: "Article", route: "/blog/[slug]" }]
6116
+ },
6117
+ {
6118
+ trigger: /\/(campaigns|ads|ad-campaigns)\b/i,
6119
+ related: [{ id: "campaign-detail", name: "Campaign Detail", route: "/campaigns/[id]" }]
6120
+ },
6121
+ {
6122
+ trigger: /\/(dashboard|admin)\b/i,
6123
+ related: [{ id: "settings", name: "Settings", route: "/settings" }]
6124
+ },
6125
+ {
6126
+ trigger: /\/pricing\b/i,
6127
+ related: [{ id: "checkout", name: "Checkout", route: "/checkout" }]
6286
6128
  }
6287
- const hasNav = /<nav\b|NavLink|navigation|sidebar.*link|Sidebar.*link/i.test(code);
6288
- const hasActiveState = /pathname|active|current|aria-current|data-active/.test(code);
6289
- if (hasNav && !hasActiveState) {
6290
- issues.push({
6291
- line: 0,
6292
- type: "NAV_NO_ACTIVE_STATE",
6293
- message: "Navigation without active/current page indicator \u2014 add active state for current route",
6294
- severity: "info"
6295
- });
6129
+ ];
6130
+ function extractInternalLinks(code) {
6131
+ const links = /* @__PURE__ */ new Set();
6132
+ const hrefRe = /href\s*=\s*["'](\/[a-z0-9/-]*)["']/gi;
6133
+ let m;
6134
+ while ((m = hrefRe.exec(code)) !== null) {
6135
+ const route = m[1];
6136
+ if (route === "/" || route.startsWith("/design-system") || route.startsWith("/#") || route.startsWith("/api"))
6137
+ continue;
6138
+ links.add(route);
6296
6139
  }
6297
- if (validRoutes && validRoutes.length > 0) {
6298
- const routeSet = new Set(validRoutes);
6299
- routeSet.add("#");
6300
- const lines = code.split("\n");
6301
- const linkHrefRe = /href\s*=\s*["'](\/[a-z0-9/-]*)["']/gi;
6302
- for (let i = 0; i < lines.length; i++) {
6303
- let match;
6304
- while ((match = linkHrefRe.exec(lines[i])) !== null) {
6305
- const target = match[1];
6306
- if (target === "/" || target.startsWith("/design-system") || target.startsWith("/api") || target.startsWith("/#"))
6307
- continue;
6308
- if (!routeSet.has(target)) {
6309
- issues.push({
6310
- line: i + 1,
6311
- type: "BROKEN_INTERNAL_LINK",
6312
- message: `Link to "${target}" \u2014 route does not exist in project`,
6313
- severity: "warning"
6314
- });
6140
+ return [...links];
6141
+ }
6142
+ function inferRelatedPages(plannedPages) {
6143
+ const plannedRoutes = new Set(plannedPages.map((p) => p.route));
6144
+ const inferred = [];
6145
+ for (const { route } of plannedPages) {
6146
+ const authRelated = AUTH_FLOW_PATTERNS[route];
6147
+ if (authRelated) {
6148
+ for (const rel of authRelated) {
6149
+ if (!plannedRoutes.has(rel)) {
6150
+ const slug = rel.slice(1);
6151
+ const name = slug.split("-").map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
6152
+ inferred.push({ id: slug, name, route: rel });
6153
+ plannedRoutes.add(rel);
6154
+ }
6155
+ }
6156
+ }
6157
+ for (const rule of PAGE_RELATIONSHIP_RULES) {
6158
+ if (rule.trigger.test(route)) {
6159
+ for (const rel of rule.related) {
6160
+ if (!plannedRoutes.has(rel.route)) {
6161
+ inferred.push(rel);
6162
+ plannedRoutes.add(rel.route);
6163
+ }
6315
6164
  }
6316
6165
  }
6317
6166
  }
6318
6167
  }
6319
- return issues;
6168
+ return inferred;
6320
6169
  }
6321
- async function autoFixCode(code) {
6322
- const fixes = [];
6323
- let fixed = code;
6324
- const beforeQuoteFix = fixed;
6325
- fixed = fixed.replace(/(:\s*'.+)\\'(\s*)$/gm, "$1'$2");
6326
- if (fixed !== beforeQuoteFix) {
6327
- fixes.push("fixed escaped closing quotes in strings");
6328
- }
6329
- const beforeEntityFix = fixed;
6330
- fixed = fixed.replace(/&lt;=/g, "<=");
6331
- fixed = fixed.replace(/&gt;=/g, ">=");
6332
- fixed = fixed.replace(/&amp;&amp;/g, "&&");
6333
- fixed = fixed.replace(/([\w)\]])\s*&lt;\s*([\w(])/g, "$1 < $2");
6334
- fixed = fixed.replace(/([\w)\]])\s*&gt;\s*([\w(])/g, "$1 > $2");
6335
- if (fixed !== beforeEntityFix) {
6336
- fixes.push("Fixed syntax issues");
6337
- }
6338
- const beforeLtFix = fixed;
6339
- fixed = fixed.replace(/>([^<{}\n]*)<(\d)/g, ">$1&lt;$2");
6340
- fixed = fixed.replace(/>([^<{}\n]*)<([^/a-zA-Z!{>\n])/g, ">$1&lt;$2");
6341
- if (fixed !== beforeLtFix) {
6342
- fixes.push("escaped < in JSX text content");
6343
- }
6344
- if (/className="[^"]*\btext-base\b[^"]*"/.test(fixed)) {
6345
- fixed = fixed.replace(/className="([^"]*)\btext-base\b([^"]*)"/g, 'className="$1text-sm$2"');
6346
- fixes.push("text-base \u2192 text-sm");
6347
- }
6348
- if (/CardTitle[^>]*className="[^"]*text-(lg|xl|2xl)/.test(fixed)) {
6349
- fixed = fixed.replace(/(CardTitle[^>]*className="[^"]*)text-(lg|xl|2xl)\b/g, "$1");
6350
- fixes.push("large text in CardTitle \u2192 removed");
6351
- }
6352
- if (/className="[^"]*\bshadow-(md|lg|xl|2xl)\b[^"]*"/.test(fixed)) {
6353
- fixed = fixed.replace(/className="([^"]*)\bshadow-(md|lg|xl|2xl)\b([^"]*)"/g, 'className="$1shadow-sm$3"');
6354
- fixes.push("heavy shadow \u2192 shadow-sm");
6355
- }
6356
- const hasHooks = /\b(useState|useEffect|useRef|useCallback|useMemo|useReducer|useContext)\b/.test(fixed);
6357
- const hasEvents = /\b(onClick|onChange|onSubmit|onBlur|onFocus|onKeyDown|onKeyUp|onMouseEnter|onMouseLeave|onScroll|onInput)\s*[={]/.test(
6358
- fixed
6170
+ function impliesFullWebsite(message) {
6171
+ return /\b(create|build|make|design)\b.{0,80}\b(website|web\s*site|web\s*app|application|app|platform|portal|marketplace|site)\b/i.test(
6172
+ message
6359
6173
  );
6360
- const hasUseClient = /^['"]use client['"]/.test(fixed.trim());
6361
- if ((hasHooks || hasEvents) && !hasUseClient) {
6362
- fixed = `'use client'
6363
-
6364
- ${fixed}`;
6365
- fixes.push('added "use client" (client features detected)');
6366
- }
6367
- if (/^['"]use client['"]/.test(fixed.trim()) && /\bexport\s+const\s+metadata\s*:\s*Metadata\s*=\s*\{/.test(fixed)) {
6368
- const metaMatch = fixed.match(/\bexport\s+const\s+metadata\s*:\s*Metadata\s*=\s*\{/);
6369
- if (metaMatch) {
6370
- const start = fixed.indexOf(metaMatch[0]);
6371
- const open = fixed.indexOf("{", start);
6372
- let depth = 1, i = open + 1;
6373
- while (i < fixed.length && depth > 0) {
6374
- if (fixed[i] === "{") depth++;
6375
- else if (fixed[i] === "}") depth--;
6376
- i++;
6174
+ }
6175
+ function extractPageNamesFromMessage(message) {
6176
+ const pages = [];
6177
+ const known = {
6178
+ home: "/",
6179
+ landing: "/",
6180
+ dashboard: "/dashboard",
6181
+ about: "/about",
6182
+ "about us": "/about",
6183
+ contact: "/contact",
6184
+ contacts: "/contacts",
6185
+ pricing: "/pricing",
6186
+ settings: "/settings",
6187
+ account: "/account",
6188
+ "personal account": "/account",
6189
+ registration: "/registration",
6190
+ signup: "/signup",
6191
+ "sign up": "/signup",
6192
+ login: "/login",
6193
+ "sign in": "/login",
6194
+ catalogue: "/catalogue",
6195
+ catalog: "/catalog",
6196
+ blog: "/blog",
6197
+ portfolio: "/portfolio",
6198
+ features: "/features",
6199
+ services: "/services",
6200
+ faq: "/faq",
6201
+ team: "/team"
6202
+ };
6203
+ const lower = message.toLowerCase();
6204
+ for (const [key, route] of Object.entries(known)) {
6205
+ if (lower.includes(key)) {
6206
+ const name = key.split(" ").map((w) => w[0].toUpperCase() + w.slice(1)).join(" ");
6207
+ const id = route.slice(1) || "home";
6208
+ if (!pages.some((p) => p.route === route)) {
6209
+ pages.push({ name, id, route });
6377
6210
  }
6378
- const tail = fixed.slice(i);
6379
- const semi = tail.match(/^\s*;/);
6380
- const removeEnd = semi ? i + (semi.index + semi[0].length) : i;
6381
- fixed = (fixed.slice(0, start) + fixed.slice(removeEnd)).replace(/\n{3,}/g, "\n\n").trim();
6382
- fixes.push('removed metadata export (conflicts with "use client")');
6383
6211
  }
6384
6212
  }
6385
- const lines = fixed.split("\n");
6386
- let hasReplacedButton = false;
6387
- for (let i = 0; i < lines.length; i++) {
6388
- if (!/<button\b/.test(lines[i])) continue;
6389
- if (lines[i].includes("aria-label")) continue;
6390
- if (/onClick=\{.*copy/i.test(lines[i])) continue;
6391
- const block = lines.slice(i, i + 5).join(" ");
6392
- if (block.includes("aria-label") || /onClick=\{.*copy/i.test(block)) continue;
6393
- lines[i] = lines[i].replace(/<button\b/g, "<Button");
6394
- hasReplacedButton = true;
6213
+ return pages;
6214
+ }
6215
+ function normalizeRequest(request, config2) {
6216
+ const changes = request.changes;
6217
+ const VALID_TYPES = [
6218
+ "update-token",
6219
+ "add-component",
6220
+ "modify-component",
6221
+ "add-layout-block",
6222
+ "modify-layout-block",
6223
+ "add-page",
6224
+ "update-page",
6225
+ "update-navigation",
6226
+ "link-shared",
6227
+ "promote-and-link"
6228
+ ];
6229
+ if (!VALID_TYPES.includes(request.type)) {
6230
+ return { error: `Unknown action "${request.type}". Valid: ${VALID_TYPES.join(", ")}` };
6395
6231
  }
6396
- if (hasReplacedButton) {
6397
- fixed = lines.join("\n");
6398
- fixed = fixed.replace(/<\/button>/g, (_match, _offset) => {
6399
- return "</Button>";
6400
- });
6401
- const openCount = (fixed.match(/<Button\b/g) || []).length;
6402
- const closeCount = (fixed.match(/<\/Button>/g) || []).length;
6403
- if (closeCount > openCount) {
6404
- let excess = closeCount - openCount;
6405
- fixed = fixed.replace(/<\/Button>/g, (m) => {
6406
- if (excess > 0) {
6407
- excess--;
6408
- return "</button>";
6409
- }
6410
- return m;
6411
- });
6412
- }
6413
- const hasButtonImport = /import\s.*\bButton\b.*from\s+['"]@\/components\/ui\/button['"]/.test(fixed);
6414
- if (!hasButtonImport) {
6415
- const lastImportIdx = fixed.lastIndexOf("\nimport ");
6416
- if (lastImportIdx !== -1) {
6417
- const lineEnd = fixed.indexOf("\n", lastImportIdx + 1);
6418
- fixed = fixed.slice(0, lineEnd + 1) + "import { Button } from '@/components/ui/button'\n" + fixed.slice(lineEnd + 1);
6419
- } else {
6420
- const insertAfter = hasUseClient ? fixed.indexOf("\n") + 1 : 0;
6421
- fixed = fixed.slice(0, insertAfter) + "import { Button } from '@/components/ui/button'\n" + fixed.slice(insertAfter);
6232
+ const findPage = (target) => config2.pages.find(
6233
+ (p) => p.id === target || p.route === target || p.name?.toLowerCase() === String(target).toLowerCase()
6234
+ );
6235
+ switch (request.type) {
6236
+ case "update-page": {
6237
+ const page = findPage(request.target);
6238
+ if (!page && changes?.pageCode) {
6239
+ const targetStr = String(request.target);
6240
+ const id = targetStr.replace(/^\//, "") || "home";
6241
+ return {
6242
+ ...request,
6243
+ type: "add-page",
6244
+ target: "new",
6245
+ changes: {
6246
+ id,
6247
+ name: changes.name || id.charAt(0).toUpperCase() + id.slice(1) || "Home",
6248
+ route: targetStr.startsWith("/") ? targetStr : `/${targetStr}`,
6249
+ ...changes
6250
+ }
6251
+ };
6252
+ }
6253
+ if (!page) {
6254
+ const available = config2.pages.map((p) => `${p.name} (${p.route})`).join(", ");
6255
+ return { error: `Page "${request.target}" not found. Available: ${available || "none"}` };
6256
+ }
6257
+ if (page.id !== request.target) {
6258
+ return { ...request, target: page.id };
6422
6259
  }
6260
+ break;
6423
6261
  }
6424
- fixes.push("<button> \u2192 <Button> (with import)");
6425
- }
6426
- const colorMap = {
6427
- "bg-zinc-950": "bg-background",
6428
- "bg-zinc-900": "bg-background",
6429
- "bg-slate-950": "bg-background",
6430
- "bg-slate-900": "bg-background",
6431
- "bg-gray-950": "bg-background",
6432
- "bg-gray-900": "bg-background",
6433
- "bg-zinc-800": "bg-muted",
6434
- "bg-slate-800": "bg-muted",
6435
- "bg-gray-800": "bg-muted",
6436
- "bg-zinc-100": "bg-muted",
6437
- "bg-slate-100": "bg-muted",
6438
- "bg-gray-100": "bg-muted",
6439
- "bg-white": "bg-background",
6440
- "bg-black": "bg-background",
6441
- "text-white": "text-foreground",
6442
- "text-black": "text-foreground",
6443
- "text-zinc-100": "text-foreground",
6444
- "text-zinc-200": "text-foreground",
6445
- "text-slate-100": "text-foreground",
6446
- "text-gray-100": "text-foreground",
6447
- "text-zinc-400": "text-muted-foreground",
6448
- "text-zinc-500": "text-muted-foreground",
6449
- "text-slate-400": "text-muted-foreground",
6450
- "text-slate-500": "text-muted-foreground",
6451
- "text-gray-400": "text-muted-foreground",
6452
- "text-gray-500": "text-muted-foreground",
6453
- "border-zinc-700": "border-border",
6454
- "border-zinc-800": "border-border",
6455
- "border-slate-700": "border-border",
6456
- "border-gray-700": "border-border",
6457
- "border-zinc-200": "border-border",
6458
- "border-slate-200": "border-border",
6459
- "border-gray-200": "border-border"
6460
- };
6461
- const isCodeContext = (classes) => /\bfont-mono\b/.test(classes) || /\bbg-zinc-950\b/.test(classes) || /\bbg-zinc-900\b/.test(classes);
6462
- const isInsideTerminalBlock = (offset) => {
6463
- const preceding = fixed.slice(Math.max(0, offset - 600), offset);
6464
- if (!/(bg-zinc-950|bg-zinc-900)/.test(preceding)) return false;
6465
- if (!/font-mono/.test(preceding)) return false;
6466
- const lastClose = Math.max(preceding.lastIndexOf("</div>"), preceding.lastIndexOf("</section>"));
6467
- const lastTerminal = Math.max(preceding.lastIndexOf("bg-zinc-950"), preceding.lastIndexOf("bg-zinc-900"));
6468
- return lastTerminal > lastClose;
6469
- };
6470
- let hadColorFix = false;
6471
- fixed = fixed.replace(/className="([^"]*)"/g, (fullMatch, classes, offset) => {
6472
- if (isCodeContext(classes)) return fullMatch;
6473
- if (isInsideTerminalBlock(offset)) return fullMatch;
6474
- let result = classes;
6475
- const accentColorRe = /\b(bg|text|border)-(emerald|blue|violet|indigo|purple|teal|cyan|sky|rose|amber)-(\d+)\b/g;
6476
- result = result.replace(accentColorRe, (m, prefix, _color, shade) => {
6477
- if (colorMap[m]) {
6478
- hadColorFix = true;
6479
- return colorMap[m];
6262
+ case "add-page": {
6263
+ if (!changes) break;
6264
+ let route = changes.route || "";
6265
+ if (route && !route.startsWith("/")) route = `/${route}`;
6266
+ if (route) changes.route = route;
6267
+ const existingByRoute = config2.pages.find((p) => p.route === route);
6268
+ if (existingByRoute && route) {
6269
+ return {
6270
+ ...request,
6271
+ type: "update-page",
6272
+ target: existingByRoute.id
6273
+ };
6480
6274
  }
6481
- const n = parseInt(shade);
6482
- if (prefix === "bg") {
6483
- if (n >= 500 && n <= 700) {
6484
- hadColorFix = true;
6485
- return "bg-primary";
6275
+ if (!changes.id && changes.name) {
6276
+ changes.id = String(changes.name).toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "");
6277
+ }
6278
+ if (!changes.id && route) {
6279
+ changes.id = route.replace(/^\//, "") || "home";
6280
+ }
6281
+ break;
6282
+ }
6283
+ case "modify-component": {
6284
+ const componentId = request.target;
6285
+ const existingComp = config2.components.find((c) => c.id === componentId);
6286
+ if (!existingComp) {
6287
+ return {
6288
+ ...request,
6289
+ type: "add-component",
6290
+ target: "new"
6291
+ };
6292
+ }
6293
+ if (changes) {
6294
+ if (typeof changes.id === "string" && changes.id !== componentId) {
6295
+ const targetExists = config2.components.some((c) => c.id === changes.id);
6296
+ if (!targetExists) {
6297
+ return { ...request, type: "add-component", target: "new" };
6298
+ }
6299
+ return {
6300
+ error: `Cannot change component "${componentId}" to "${changes.id}" \u2014 "${changes.id}" already exists.`
6301
+ };
6486
6302
  }
6487
- if (n >= 100 && n <= 200) {
6488
- hadColorFix = true;
6489
- return "bg-primary/10";
6303
+ if (typeof changes.name === "string") {
6304
+ const newName = changes.name.toLowerCase();
6305
+ const curName = existingComp.name.toLowerCase();
6306
+ const curId = componentId.toLowerCase();
6307
+ const nameOk = newName === curName || newName === curId || newName.includes(curId) || curId.includes(newName);
6308
+ if (!nameOk) {
6309
+ delete changes.name;
6310
+ }
6490
6311
  }
6491
- if (n >= 800) {
6492
- hadColorFix = true;
6493
- return "bg-muted";
6312
+ }
6313
+ break;
6314
+ }
6315
+ case "add-component": {
6316
+ if (changes) {
6317
+ const shadcn = changes.shadcnComponent;
6318
+ const id = changes.id;
6319
+ if (shadcn && id && id !== shadcn) {
6320
+ changes.id = shadcn;
6494
6321
  }
6495
6322
  }
6496
- if (prefix === "text") {
6497
- if (n >= 400 && n <= 600) {
6498
- hadColorFix = true;
6499
- return "text-primary";
6323
+ break;
6324
+ }
6325
+ case "link-shared": {
6326
+ if (changes) {
6327
+ const page = findPage(request.target);
6328
+ if (!page) {
6329
+ const available = config2.pages.map((p) => `${p.name} (${p.route})`).join(", ");
6330
+ return { error: `Page "${request.target}" not found for link-shared. Available: ${available || "none"}` };
6500
6331
  }
6501
- if (n >= 100 && n <= 300) {
6502
- hadColorFix = true;
6503
- return "text-foreground";
6332
+ if (page.id !== request.target) {
6333
+ return { ...request, target: page.id };
6504
6334
  }
6505
6335
  }
6506
- if (prefix === "border") {
6507
- hadColorFix = true;
6508
- return "border-primary";
6336
+ break;
6337
+ }
6338
+ case "promote-and-link": {
6339
+ const sourcePage = findPage(request.target);
6340
+ if (!sourcePage) {
6341
+ const available = config2.pages.map((p) => `${p.name} (${p.route})`).join(", ");
6342
+ return {
6343
+ error: `Source page "${request.target}" not found for promote-and-link. Available: ${available || "none"}`
6344
+ };
6509
6345
  }
6510
- return m;
6511
- });
6512
- const neutralColorRe = /\b(bg|text|border)-(zinc|slate|gray|neutral|stone)-(\d+)\b/g;
6513
- result = result.replace(neutralColorRe, (m, prefix, _color, shade) => {
6514
- if (colorMap[m]) {
6515
- hadColorFix = true;
6516
- return colorMap[m];
6346
+ if (sourcePage.id !== request.target) {
6347
+ return { ...request, target: sourcePage.id };
6517
6348
  }
6518
- const n = parseInt(shade);
6519
- if (prefix === "bg") {
6520
- if (n >= 800) {
6521
- hadColorFix = true;
6522
- return "bg-background";
6523
- }
6524
- if (n >= 100 && n <= 300) {
6525
- hadColorFix = true;
6526
- return "bg-muted";
6527
- }
6349
+ break;
6350
+ }
6351
+ }
6352
+ return request;
6353
+ }
6354
+ function applyDefaults(request) {
6355
+ if (request.type === "add-page" && request.changes && typeof request.changes === "object") {
6356
+ const changes = request.changes;
6357
+ const now = (/* @__PURE__ */ new Date()).toISOString();
6358
+ const name = changes.name || "New Page";
6359
+ let id = changes.id || name.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "");
6360
+ if (!/^[a-z]/.test(id)) id = `page-${id}`;
6361
+ const route = changes.route || `/${id}`;
6362
+ const hasPageCode = typeof changes.pageCode === "string" && changes.pageCode.trim() !== "";
6363
+ const base = {
6364
+ id,
6365
+ name,
6366
+ route: route.startsWith("/") ? route : `/${route}`,
6367
+ layout: changes.layout || "centered",
6368
+ title: changes.title || name,
6369
+ description: changes.description || `${name} page`,
6370
+ createdAt: changes.createdAt || now,
6371
+ updatedAt: changes.updatedAt || now,
6372
+ requiresAuth: changes.requiresAuth ?? false,
6373
+ noIndex: changes.noIndex ?? false
6374
+ };
6375
+ const sections = Array.isArray(changes.sections) ? changes.sections.map((section, idx) => ({
6376
+ id: section.id || `section-${idx}`,
6377
+ name: section.name || `Section ${idx + 1}`,
6378
+ componentId: section.componentId || "button",
6379
+ order: typeof section.order === "number" ? section.order : idx,
6380
+ props: section.props || {}
6381
+ })) : [];
6382
+ return {
6383
+ ...request,
6384
+ changes: {
6385
+ ...base,
6386
+ sections,
6387
+ ...hasPageCode ? { pageCode: changes.pageCode, generatedWithPageCode: true } : {},
6388
+ ...changes.pageType ? { pageType: changes.pageType } : {},
6389
+ ...changes.structuredContent ? { structuredContent: changes.structuredContent } : {}
6528
6390
  }
6529
- if (prefix === "text") {
6530
- if (n >= 100 && n <= 300) {
6531
- hadColorFix = true;
6532
- return "text-foreground";
6391
+ };
6392
+ }
6393
+ if (request.type === "add-component" && request.changes && typeof request.changes === "object") {
6394
+ const changes = request.changes;
6395
+ const now = (/* @__PURE__ */ new Date()).toISOString();
6396
+ const validSizeNames = ["xs", "sm", "md", "lg", "xl"];
6397
+ let normalizedVariants = [];
6398
+ if (Array.isArray(changes.variants)) {
6399
+ normalizedVariants = changes.variants.map((v) => {
6400
+ if (typeof v === "string") return { name: v, className: "" };
6401
+ if (v && typeof v === "object" && "name" in v) {
6402
+ return {
6403
+ name: v.name,
6404
+ className: v.className ?? ""
6405
+ };
6533
6406
  }
6534
- if (n >= 400 && n <= 600) {
6535
- hadColorFix = true;
6536
- return "text-muted-foreground";
6407
+ return { name: "default", className: "" };
6408
+ });
6409
+ }
6410
+ let normalizedSizes = [];
6411
+ if (Array.isArray(changes.sizes)) {
6412
+ normalizedSizes = changes.sizes.map((s) => {
6413
+ if (typeof s === "string") {
6414
+ const name = validSizeNames.includes(s) ? s : "md";
6415
+ return { name, className: "" };
6537
6416
  }
6538
- }
6539
- if (prefix === "border") {
6540
- hadColorFix = true;
6541
- return "border-border";
6542
- }
6543
- return m;
6544
- });
6545
- if (result !== classes) return `className="${result}"`;
6546
- return fullMatch;
6547
- });
6548
- if (hadColorFix) fixes.push("raw colors \u2192 semantic tokens");
6549
- const lucideImportMatch = fixed.match(/import\s*\{([^}]+)\}\s*from\s*["']lucide-react["']/);
6550
- if (lucideImportMatch) {
6551
- let lucideExports = null;
6552
- try {
6553
- const { createRequire } = await import("module");
6554
- const require2 = createRequire(process.cwd() + "/package.json");
6555
- const lr = require2("lucide-react");
6556
- lucideExports = new Set(Object.keys(lr).filter((k) => /^[A-Z]/.test(k)));
6557
- } catch {
6417
+ if (s && typeof s === "object" && "name" in s) {
6418
+ const raw = s.name;
6419
+ const name = validSizeNames.includes(raw) ? raw : "md";
6420
+ return { name, className: s.className ?? "" };
6421
+ }
6422
+ return { name: "md", className: "" };
6423
+ });
6558
6424
  }
6559
- if (lucideExports) {
6560
- const nonLucideImports = /* @__PURE__ */ new Set();
6561
- for (const m of fixed.matchAll(/import\s*\{([^}]+)\}\s*from\s*["'](?!lucide-react)([^"']+)["']/g)) {
6562
- m[1].split(",").map((s) => s.trim()).filter(Boolean).forEach((n) => nonLucideImports.add(n));
6563
- }
6564
- const iconNames = lucideImportMatch[1].split(",").map((s) => s.trim()).filter(Boolean);
6565
- const duplicates = iconNames.filter((name) => nonLucideImports.has(name));
6566
- let newImport = lucideImportMatch[1];
6567
- for (const dup of duplicates) {
6568
- newImport = newImport.replace(new RegExp(`\\b${dup}\\b,?\\s*`), "");
6569
- fixes.push(`removed ${dup} from lucide import (conflicts with UI component import)`);
6425
+ return {
6426
+ ...request,
6427
+ changes: {
6428
+ ...changes,
6429
+ variants: normalizedVariants,
6430
+ sizes: normalizedSizes,
6431
+ createdAt: now,
6432
+ updatedAt: now
6570
6433
  }
6571
- const invalid = iconNames.filter((name) => !lucideExports.has(name) && !nonLucideImports.has(name));
6572
- if (invalid.length > 0) {
6573
- const fallback = "Circle";
6574
- for (const bad of invalid) {
6575
- const re = new RegExp(`\\b${bad}\\b`, "g");
6576
- newImport = newImport.replace(re, fallback);
6577
- fixed = fixed.replace(re, fallback);
6434
+ };
6435
+ }
6436
+ if (request.type === "modify-component" && request.changes && typeof request.changes === "object") {
6437
+ const changes = request.changes;
6438
+ const validSizeNames = ["xs", "sm", "md", "lg", "xl"];
6439
+ let normalizedVariants;
6440
+ if (Array.isArray(changes.variants)) {
6441
+ normalizedVariants = changes.variants.map((v) => {
6442
+ if (typeof v === "string") return { name: v, className: "" };
6443
+ if (v && typeof v === "object" && "name" in v) {
6444
+ return {
6445
+ name: v.name,
6446
+ className: v.className ?? ""
6447
+ };
6448
+ }
6449
+ return { name: "default", className: "" };
6450
+ });
6451
+ }
6452
+ let normalizedSizes;
6453
+ if (Array.isArray(changes.sizes)) {
6454
+ normalizedSizes = changes.sizes.map((s) => {
6455
+ if (typeof s === "string") {
6456
+ const name = validSizeNames.includes(s) ? s : "md";
6457
+ return { name, className: "" };
6458
+ }
6459
+ if (s && typeof s === "object" && "name" in s) {
6460
+ const raw = s.name;
6461
+ const name = validSizeNames.includes(raw) ? raw : "md";
6462
+ return { name, className: s.className ?? "" };
6578
6463
  }
6579
- fixes.push(`invalid lucide icons \u2192 ${fallback}: ${invalid.join(", ")}`);
6464
+ return { name: "md", className: "" };
6465
+ });
6466
+ }
6467
+ return {
6468
+ ...request,
6469
+ changes: {
6470
+ ...changes,
6471
+ ...normalizedVariants !== void 0 && { variants: normalizedVariants },
6472
+ ...normalizedSizes !== void 0 && { sizes: normalizedSizes }
6580
6473
  }
6581
- if (duplicates.length > 0 || invalid.length > 0) {
6582
- const importedNames = [
6583
- ...new Set(
6584
- newImport.split(",").map((s) => s.trim()).filter(Boolean)
6585
- )
6586
- ];
6587
- const originalImportLine = lucideImportMatch[0];
6588
- fixed = fixed.replace(originalImportLine, `import { ${importedNames.join(", ")} } from "lucide-react"`);
6474
+ };
6475
+ }
6476
+ return request;
6477
+ }
6478
+
6479
+ // src/utils/page-analyzer.ts
6480
+ var FORM_COMPONENTS = /* @__PURE__ */ new Set(["Input", "Textarea", "Label", "Select", "Checkbox", "Switch"]);
6481
+ var VISUAL_WORDS = /\b(grid lines?|glow|radial|gradient|blur|shadow|overlay|animation|particles?|dots?|vertical|horizontal|decorat|behind|background|divider|spacer|wrapper|container|inner|outer|absolute|relative|translate|opacity|z-index|transition)\b/i;
6482
+ function analyzePageCode(code) {
6483
+ return {
6484
+ sections: extractSections(code),
6485
+ componentUsage: extractComponentUsage(code),
6486
+ iconCount: extractIconCount(code),
6487
+ layoutPattern: inferLayoutPattern(code),
6488
+ hasForm: detectFormUsage(code),
6489
+ analyzedAt: (/* @__PURE__ */ new Date()).toISOString()
6490
+ };
6491
+ }
6492
+ function extractSections(code) {
6493
+ const sections = [];
6494
+ const seen = /* @__PURE__ */ new Set();
6495
+ const commentRe = /\{\/\*\s*(.+?)\s*\*\/\}/g;
6496
+ let m;
6497
+ while ((m = commentRe.exec(code)) !== null) {
6498
+ const raw = m[1].trim();
6499
+ const name = raw.replace(/[─━—–]+/g, "").replace(/\s*section\s*$/i, "").replace(/^section\s*:\s*/i, "").trim();
6500
+ if (!name || name.length <= 1 || name.length >= 40) continue;
6501
+ if (seen.has(name.toLowerCase())) continue;
6502
+ const wordCount = name.split(/\s+/).length;
6503
+ if (wordCount > 5) continue;
6504
+ if (/[{}()=<>;:`"']/.test(name)) continue;
6505
+ if (/^[a-z]/.test(name) && wordCount > 2) continue;
6506
+ if (VISUAL_WORDS.test(name)) continue;
6507
+ seen.add(name.toLowerCase());
6508
+ sections.push({ name, order: sections.length });
6509
+ }
6510
+ if (sections.length === 0) {
6511
+ const sectionTagRe = /<section[^>]*>[\s\S]*?<h[12][^>]*>\s*([^<]+)/g;
6512
+ while ((m = sectionTagRe.exec(code)) !== null) {
6513
+ const name = m[1].trim();
6514
+ if (name && name.length > 1 && name.length < 40 && !seen.has(name.toLowerCase())) {
6515
+ seen.add(name.toLowerCase());
6516
+ sections.push({ name, order: sections.length });
6589
6517
  }
6590
6518
  }
6591
6519
  }
6592
- const lucideImportMatch2 = fixed.match(/import\s*\{([^}]+)\}\s*from\s*["']lucide-react["']/);
6593
- if (lucideImportMatch2) {
6594
- let lucideExports2 = null;
6595
- try {
6596
- const { createRequire } = await import("module");
6597
- const req = createRequire(process.cwd() + "/package.json");
6598
- const lr = req("lucide-react");
6599
- lucideExports2 = new Set(Object.keys(lr).filter((k) => /^[A-Z]/.test(k)));
6600
- } catch {
6520
+ return sections;
6521
+ }
6522
+ function extractComponentUsage(code) {
6523
+ const usage = {};
6524
+ const importRe = /import\s*\{([^}]+)\}\s*from\s*['"]@\/components\/ui\/[^'"]+['"]/g;
6525
+ const importedComponents = [];
6526
+ let m;
6527
+ while ((m = importRe.exec(code)) !== null) {
6528
+ const names = m[1].split(",").map((s) => s.trim()).filter(Boolean);
6529
+ importedComponents.push(...names);
6530
+ }
6531
+ for (const comp of importedComponents) {
6532
+ const re = new RegExp(`<${comp}[\\s/>]`, "g");
6533
+ const matches = code.match(re);
6534
+ usage[comp] = matches ? matches.length : 0;
6535
+ }
6536
+ return usage;
6537
+ }
6538
+ function extractIconCount(code) {
6539
+ const m = code.match(/import\s*\{([^}]+)\}\s*from\s*['"]lucide-react['"]/);
6540
+ if (!m) return 0;
6541
+ return m[1].split(",").map((s) => s.trim()).filter(Boolean).length;
6542
+ }
6543
+ function inferLayoutPattern(code) {
6544
+ const funcBodyMatch = code.match(/return\s*\(\s*(<[^]*)/s);
6545
+ const topLevel = funcBodyMatch ? funcBodyMatch[1].slice(0, 500) : code.slice(0, 800);
6546
+ if (/grid-cols|grid\s+md:grid-cols|grid\s+lg:grid-cols/.test(topLevel)) return "grid";
6547
+ if (/sidebar|aside/.test(topLevel)) return "sidebar";
6548
+ if (/max-w-\d|mx-auto|container/.test(topLevel)) return "centered";
6549
+ if (/min-h-screen|min-h-svh/.test(topLevel)) return "full-width";
6550
+ return "unknown";
6551
+ }
6552
+ function detectFormUsage(code) {
6553
+ const importRe = /import\s*\{([^}]+)\}\s*from\s*['"]@\/components\/ui\/[^'"]+['"]/g;
6554
+ let m;
6555
+ while ((m = importRe.exec(code)) !== null) {
6556
+ const names = m[1].split(",").map((s) => s.trim());
6557
+ if (names.some((n) => FORM_COMPONENTS.has(n))) return true;
6558
+ }
6559
+ return false;
6560
+ }
6561
+ function summarizePageAnalysis(pageName, route, analysis) {
6562
+ const parts = [`${pageName} (${route})`];
6563
+ if (analysis.sections && analysis.sections.length > 0) {
6564
+ parts.push(`sections: ${analysis.sections.map((s) => s.name).join(", ")}`);
6565
+ }
6566
+ if (analysis.componentUsage) {
6567
+ const entries = Object.entries(analysis.componentUsage).filter(([, c]) => c > 0);
6568
+ if (entries.length > 0) {
6569
+ parts.push(`uses: ${entries.map(([n, c]) => `${n}(${c})`).join(", ")}`);
6601
6570
  }
6602
- if (lucideExports2) {
6603
- const allImportedNames = /* @__PURE__ */ new Set();
6604
- for (const m of fixed.matchAll(/import\s*\{([^}]+)\}\s*from/g)) {
6605
- m[1].split(",").map((s) => s.trim()).filter(Boolean).forEach((n) => allImportedNames.add(n));
6606
- }
6607
- for (const m of fixed.matchAll(/import\s+([A-Z]\w+)\s+from/g)) {
6608
- allImportedNames.add(m[1]);
6609
- }
6610
- const lucideImported = new Set(
6611
- lucideImportMatch2[1].split(",").map((s) => s.trim()).filter(Boolean)
6612
- );
6613
- const jsxIconRefs = [...new Set([...fixed.matchAll(/<([A-Z][a-zA-Z]*Icon)\s/g)].map((m) => m[1]))];
6614
- const missing = [];
6615
- for (const ref of jsxIconRefs) {
6616
- if (allImportedNames.has(ref)) continue;
6617
- if (fixed.includes(`function ${ref}`) || fixed.includes(`const ${ref}`)) continue;
6618
- const baseName = ref.replace(/Icon$/, "");
6619
- if (lucideExports2.has(ref)) {
6620
- missing.push(ref);
6621
- lucideImported.add(ref);
6622
- } else if (lucideExports2.has(baseName)) {
6623
- const re = new RegExp(`\\b${ref}\\b`, "g");
6624
- fixed = fixed.replace(re, baseName);
6625
- missing.push(baseName);
6626
- lucideImported.add(baseName);
6627
- fixes.push(`renamed ${ref} \u2192 ${baseName} (lucide-react)`);
6628
- } else {
6629
- const fallback = "Circle";
6630
- const re = new RegExp(`\\b${ref}\\b`, "g");
6631
- fixed = fixed.replace(re, fallback);
6632
- lucideImported.add(fallback);
6633
- fixes.push(`unknown icon ${ref} \u2192 ${fallback}`);
6634
- }
6635
- }
6636
- if (missing.length > 0) {
6637
- const allNames = [...lucideImported];
6638
- const origLine = lucideImportMatch2[0];
6639
- fixed = fixed.replace(origLine, `import { ${allNames.join(", ")} } from "lucide-react"`);
6640
- fixes.push(`added missing lucide imports: ${missing.join(", ")}`);
6641
- }
6571
+ }
6572
+ if (analysis.layoutPattern && analysis.layoutPattern !== "unknown") {
6573
+ parts.push(`layout: ${analysis.layoutPattern}`);
6574
+ }
6575
+ if (analysis.hasForm) parts.push("has-form");
6576
+ return `- ${parts.join(". ")}`;
6577
+ }
6578
+
6579
+ // src/utils/concurrency.ts
6580
+ async function pMap(items, fn, concurrency = 3) {
6581
+ const results = new Array(items.length);
6582
+ let nextIndex = 0;
6583
+ async function worker() {
6584
+ while (nextIndex < items.length) {
6585
+ const i = nextIndex++;
6586
+ results[i] = await fn(items[i], i);
6642
6587
  }
6643
6588
  }
6644
- fixed = fixed.replace(/className="([^"]*)"/g, (_match, inner) => {
6645
- const cleaned = inner.replace(/\s{2,}/g, " ").trim();
6646
- return `className="${cleaned}"`;
6647
- });
6648
- let imgCounter = 1;
6649
- const beforeImgFix = fixed;
6650
- fixed = fixed.replace(/["']\/api\/placeholder\/(\d+)\/(\d+)["']/g, (_m, w, h) => {
6651
- return `"https://picsum.photos/${w}/${h}?random=${imgCounter++}"`;
6652
- });
6653
- fixed = fixed.replace(/["']\/placeholder-avatar[^"']*["']/g, () => {
6654
- return `"https://i.pravatar.cc/150?u=user${imgCounter++}"`;
6655
- });
6656
- fixed = fixed.replace(/["']https?:\/\/via\.placeholder\.com\/(\d+)x?(\d*)(?:\/[^"']*)?\/?["']/g, (_m, w, h) => {
6657
- const height = h || w;
6658
- return `"https://picsum.photos/${w}/${height}?random=${imgCounter++}"`;
6659
- });
6660
- fixed = fixed.replace(/["']\/images\/[^"']+\.(?:jpg|jpeg|png|webp|gif)["']/g, () => {
6661
- return `"https://picsum.photos/800/400?random=${imgCounter++}"`;
6662
- });
6663
- fixed = fixed.replace(/["']\/placeholder[^"']*\.(?:jpg|jpeg|png|webp)["']/g, () => {
6664
- return `"https://picsum.photos/800/400?random=${imgCounter++}"`;
6589
+ const workers = Array.from({ length: Math.min(concurrency, items.length) }, () => worker());
6590
+ await Promise.all(workers);
6591
+ return results;
6592
+ }
6593
+
6594
+ // src/commands/chat/split-generator.ts
6595
+ function buildExistingPagesContext(config2) {
6596
+ const pages = config2.pages || [];
6597
+ const analyzed = pages.filter((p) => p.pageAnalysis);
6598
+ if (analyzed.length === 0) return "";
6599
+ const lines = analyzed.map((p) => {
6600
+ return summarizePageAnalysis(p.name || p.id, p.route, p.pageAnalysis);
6665
6601
  });
6666
- if (fixed !== beforeImgFix) {
6667
- fixes.push("placeholder images \u2192 working URLs (picsum/pravatar)");
6602
+ let ctx = `EXISTING PAGES CONTEXT:
6603
+ ${lines.join("\n")}
6604
+
6605
+ Use consistent component choices, spacing, and layout patterns across all pages. Match the style and structure of existing pages.`;
6606
+ const sp = config2.stylePatterns;
6607
+ if (sp && typeof sp === "object") {
6608
+ const parts = [];
6609
+ if (sp.card) parts.push(`Cards: ${sp.card}`);
6610
+ if (sp.section) parts.push(`Sections: ${sp.section}`);
6611
+ if (sp.terminal) parts.push(`Terminal blocks: ${sp.terminal}`);
6612
+ if (sp.iconContainer) parts.push(`Icon containers: ${sp.iconContainer}`);
6613
+ if (sp.heroHeadline) parts.push(`Hero headline: ${sp.heroHeadline}`);
6614
+ if (sp.sectionTitle) parts.push(`Section title: ${sp.sectionTitle}`);
6615
+ if (parts.length > 0) {
6616
+ ctx += `
6617
+
6618
+ PROJECT STYLE PATTERNS (from sync \u2014 match these exactly):
6619
+ ${parts.join("\n")}`;
6620
+ }
6621
+ }
6622
+ return ctx;
6623
+ }
6624
+ function extractStyleContext(pageCode) {
6625
+ const unique = (arr) => [...new Set(arr)];
6626
+ const cardClasses = (pageCode.match(/className="[^"]*(?:rounded|border|shadow|bg-card)[^"]*"/g) || []).map((m) => m.replace(/className="|"/g, "")).filter((c) => c.includes("rounded") || c.includes("border") || c.includes("card"));
6627
+ const sectionSpacing = unique(pageCode.match(/py-\d+(?:\s+md:py-\d+)?/g) || []);
6628
+ const headingStyles = unique(pageCode.match(/text-(?:\d*xl|lg)\s+font-(?:bold|semibold|medium)/g) || []);
6629
+ const colorPatterns = unique(
6630
+ (pageCode.match(
6631
+ /(?:text|bg|border)-(?:primary|secondary|muted|accent|card|destructive|foreground|background)\S*/g
6632
+ ) || []).concat(
6633
+ pageCode.match(
6634
+ /(?:text|bg|border)-(?:emerald|blue|violet|rose|amber|zinc|slate|gray|green|red|orange|indigo|purple|teal|cyan)\S*/g
6635
+ ) || []
6636
+ )
6637
+ );
6638
+ const iconPatterns = unique(pageCode.match(/(?:rounded-\S+\s+)?p-\d+(?:\.\d+)?\s*(?:bg-\S+)?/g) || []).filter(
6639
+ (p) => p.includes("bg-") || p.includes("rounded")
6640
+ );
6641
+ const buttonPatterns = unique(
6642
+ (pageCode.match(/className="[^"]*(?:hover:|active:)[^"]*"/g) || []).map((m) => m.replace(/className="|"/g, "")).filter((c) => c.includes("px-") || c.includes("py-") || c.includes("rounded"))
6643
+ );
6644
+ const bgPatterns = unique(pageCode.match(/bg-(?:muted|card|background|zinc|slate|gray)\S*/g) || []);
6645
+ const gapPatterns = unique(pageCode.match(/gap-\d+/g) || []);
6646
+ const gridPatterns = unique(pageCode.match(/grid-cols-\d+|md:grid-cols-\d+|lg:grid-cols-\d+/g) || []);
6647
+ const containerPatterns = unique(pageCode.match(/container\s+max-w-\S+|max-w-\d+xl\s+mx-auto/g) || []);
6648
+ const lines = [];
6649
+ if (containerPatterns.length > 0) {
6650
+ lines.push(`Container (MUST match for alignment with header/footer): ${containerPatterns[0]} px-4`);
6668
6651
  }
6669
- return { code: fixed, fixes };
6652
+ if (cardClasses.length > 0) lines.push(`Cards: ${unique(cardClasses).slice(0, 4).join(" | ")}`);
6653
+ if (sectionSpacing.length > 0) lines.push(`Section spacing: ${sectionSpacing.join(", ")}`);
6654
+ if (headingStyles.length > 0) lines.push(`Headings: ${headingStyles.join(", ")}`);
6655
+ if (colorPatterns.length > 0) lines.push(`Colors: ${colorPatterns.slice(0, 15).join(", ")}`);
6656
+ if (iconPatterns.length > 0) lines.push(`Icon containers: ${iconPatterns.slice(0, 4).join(" | ")}`);
6657
+ if (buttonPatterns.length > 0) lines.push(`Buttons: ${buttonPatterns.slice(0, 3).join(" | ")}`);
6658
+ if (bgPatterns.length > 0) lines.push(`Section backgrounds: ${bgPatterns.slice(0, 6).join(", ")}`);
6659
+ if (gapPatterns.length > 0) lines.push(`Gaps: ${gapPatterns.join(", ")}`);
6660
+ if (gridPatterns.length > 0) lines.push(`Grids: ${gridPatterns.join(", ")}`);
6661
+ if (lines.length === 0) return "";
6662
+ return `STYLE CONTEXT (match these patterns exactly for visual consistency with the Home page):
6663
+ ${lines.map((l) => ` - ${l}`).join("\n")}`;
6670
6664
  }
6671
- function formatIssues(issues) {
6672
- if (issues.length === 0) return "";
6673
- const errors = issues.filter((i) => i.severity === "error");
6674
- const warnings = issues.filter((i) => i.severity === "warning");
6675
- const infos = issues.filter((i) => i.severity === "info");
6676
- const lines = [];
6677
- if (errors.length > 0) {
6678
- lines.push(` \u274C ${errors.length} error(s):`);
6679
- for (const e of errors) {
6680
- lines.push(` L${e.line}: [${e.type}] ${e.message}`);
6681
- }
6665
+ async function splitGeneratePages(spinner, message, modCtx, provider, parseOpts) {
6666
+ let pageNames = [];
6667
+ spinner.start("Phase 1/4 \u2014 Planning pages...");
6668
+ try {
6669
+ const planResult = await parseModification(message, modCtx, provider, { ...parseOpts, planOnly: true });
6670
+ const pageReqs = planResult.requests.filter((r) => r.type === "add-page");
6671
+ pageNames = pageReqs.map((r) => {
6672
+ const c = r.changes;
6673
+ const name = c.name || c.id || "page";
6674
+ const id = c.id || name.toLowerCase().replace(/\s+/g, "-");
6675
+ const route = c.route || `/${id}`;
6676
+ return { name, id, route };
6677
+ });
6678
+ } catch {
6679
+ spinner.text = "AI plan failed \u2014 extracting pages from your request...";
6682
6680
  }
6683
- if (warnings.length > 0) {
6684
- lines.push(` \u26A0\uFE0F ${warnings.length} warning(s):`);
6685
- for (const w of warnings) {
6686
- lines.push(` L${w.line}: [${w.type}] ${w.message}`);
6687
- }
6681
+ if (pageNames.length === 0) {
6682
+ pageNames = extractPageNamesFromMessage(message);
6688
6683
  }
6689
- if (infos.length > 0) {
6690
- lines.push(` \u2139\uFE0F ${infos.length} info:`);
6691
- for (const i of infos) {
6692
- lines.push(` L${i.line}: [${i.type}] ${i.message}`);
6693
- }
6684
+ if (pageNames.length === 0) {
6685
+ spinner.fail("Could not determine pages to create");
6686
+ return [];
6694
6687
  }
6695
- return lines.join("\n");
6696
- }
6697
- function checkDesignConsistency(code) {
6698
- const warnings = [];
6699
- const hexPattern = /\[#[0-9a-fA-F]{3,8}\]/g;
6700
- for (const match of code.matchAll(hexPattern)) {
6701
- warnings.push({
6702
- type: "hardcoded-color",
6703
- message: `Hardcoded color ${match[0]} \u2014 use a design token (e.g., bg-primary) instead`
6704
- });
6688
+ pageNames = deduplicatePages(pageNames);
6689
+ const hasHomePage = pageNames.some((p) => p.route === "/");
6690
+ if (!hasHomePage) {
6691
+ const userPages = (modCtx.config.pages || []).filter(
6692
+ (p) => p.id !== "home" && p.id !== "new" && p.route !== "/"
6693
+ );
6694
+ const isFreshProject = userPages.length === 0;
6695
+ if (isFreshProject || impliesFullWebsite(message)) {
6696
+ pageNames.unshift({ name: "Home", id: "home", route: "/" });
6697
+ }
6705
6698
  }
6706
- const spacingPattern = /[pm][trblxy]?-\[\d+px\]/g;
6707
- for (const match of code.matchAll(spacingPattern)) {
6708
- warnings.push({
6709
- type: "arbitrary-spacing",
6710
- message: `Arbitrary spacing ${match[0]} \u2014 use Tailwind spacing scale instead`
6711
- });
6699
+ const existingRoutes = new Set((modCtx.config.pages || []).map((p) => p.route).filter(Boolean));
6700
+ const inferred = inferRelatedPages(pageNames).filter((p) => !existingRoutes.has(p.route));
6701
+ if (inferred.length > 0) {
6702
+ pageNames.push(...inferred);
6703
+ pageNames = deduplicatePages(pageNames);
6712
6704
  }
6713
- return warnings;
6714
- }
6715
- function verifyIncrementalEdit(before, after) {
6716
- const issues = [];
6717
- const hookPattern = /\buse[A-Z]\w+\s*\(/;
6718
- if (hookPattern.test(after) && !after.includes("'use client'") && !after.includes('"use client"')) {
6719
- issues.push({
6720
- type: "missing-use-client",
6721
- message: 'Code uses React hooks but missing "use client" directive'
6722
- });
6705
+ const allRoutes = pageNames.map((p) => p.route).join(", ");
6706
+ const allPagesList = pageNames.map((p) => `${p.name} (${p.route})`).join(", ");
6707
+ const inferredNote = inferred.length > 0 ? ` (${inferred.length} auto-inferred)` : "";
6708
+ spinner.succeed(`Phase 1/4 \u2014 Found ${pageNames.length} pages${inferredNote}: ${allPagesList}`);
6709
+ const homeIdx = pageNames.findIndex((p) => p.route === "/");
6710
+ const homePage = homeIdx !== -1 ? pageNames[homeIdx] : pageNames[0];
6711
+ const remainingPages = pageNames.filter((_, i) => i !== (homeIdx !== -1 ? homeIdx : 0));
6712
+ spinner.start(`Phase 2/4 \u2014 Generating ${homePage.name} page (sets design direction)...`);
6713
+ let homeRequest = null;
6714
+ let homePageCode = "";
6715
+ try {
6716
+ const homeResult = await parseModification(
6717
+ `Create ONE page called "${homePage.name}" at route "${homePage.route}". Context: ${message}. This REPLACES the default placeholder page \u2014 generate a complete, content-rich landing page for the project described above. Generate complete pageCode. Include a branded site-wide <header> with navigation links to ALL these pages: ${allPagesList}. Use these EXACT routes in navigation: ${allRoutes}. Include a <footer> at the bottom. Make it visually polished \u2014 this page sets the design direction for the entire site. Do not generate other pages.`,
6718
+ modCtx,
6719
+ provider,
6720
+ parseOpts
6721
+ );
6722
+ const codePage = homeResult.requests.find((r) => r.type === "add-page");
6723
+ if (codePage) {
6724
+ homeRequest = codePage;
6725
+ homePageCode = codePage.changes?.pageCode || "";
6726
+ }
6727
+ } catch {
6723
6728
  }
6724
- if (!after.includes("export default")) {
6725
- issues.push({
6726
- type: "missing-default-export",
6727
- message: "Missing default export \u2014 page component must have a default export"
6728
- });
6729
+ if (!homeRequest) {
6730
+ homeRequest = {
6731
+ type: "add-page",
6732
+ target: "new",
6733
+ changes: { id: homePage.id, name: homePage.name, route: homePage.route }
6734
+ };
6729
6735
  }
6730
- const importRegex = /import\s+\{([^}]+)\}\s+from/g;
6731
- const beforeImports = /* @__PURE__ */ new Set();
6732
- const afterImports = /* @__PURE__ */ new Set();
6733
- for (const match of before.matchAll(importRegex)) {
6734
- match[1].split(",").forEach((s) => beforeImports.add(s.trim()));
6736
+ spinner.succeed(`Phase 2/4 \u2014 ${homePage.name} page generated`);
6737
+ spinner.start("Phase 3/4 \u2014 Extracting design patterns...");
6738
+ const styleContext = homePageCode ? extractStyleContext(homePageCode) : "";
6739
+ if (styleContext) {
6740
+ const lineCount = styleContext.split("\n").length - 1;
6741
+ spinner.succeed(`Phase 3/4 \u2014 Extracted ${lineCount} style patterns from ${homePage.name}`);
6742
+ } else {
6743
+ spinner.succeed("Phase 3/4 \u2014 No style patterns extracted (Home page had no code)");
6735
6744
  }
6736
- for (const match of after.matchAll(importRegex)) {
6737
- match[1].split(",").forEach((s) => afterImports.add(s.trim()));
6745
+ if (remainingPages.length === 0) {
6746
+ return [homeRequest];
6738
6747
  }
6739
- for (const symbol of beforeImports) {
6740
- if (!afterImports.has(symbol) && symbol.length > 0) {
6741
- const codeWithoutImports = after.replace(/^import\s+.*$/gm, "");
6742
- const symbolRegex = new RegExp(`\\b${symbol}\\b`);
6743
- if (symbolRegex.test(codeWithoutImports)) {
6744
- issues.push({
6745
- type: "missing-import",
6746
- symbol,
6747
- message: `Import for "${symbol}" was removed but symbol is still used in code`
6748
- });
6748
+ spinner.start(`Phase 4/4 \u2014 Generating ${remainingPages.length} pages in parallel...`);
6749
+ const sharedNote = "Header and Footer are shared components rendered by the root layout. Do NOT include any site-wide <header>, <nav>, or <footer> in this page. Start with the main content directly.";
6750
+ const routeNote = `EXISTING ROUTES in this project: ${allRoutes}. All internal links MUST point to one of these routes. If a target doesn't exist, use href="#".`;
6751
+ const alignmentNote = 'CRITICAL LAYOUT RULE: Every <section> must wrap its content in a container div matching the header width. Use the EXACT same container classes as shown in the style context (e.g. className="container max-w-6xl px-4" or className="max-w-6xl mx-auto px-4"). Inner content can use narrower max-w for text centering, but the outer section container MUST match.';
6752
+ const existingPagesContext = buildExistingPagesContext(modCtx.config);
6753
+ const AI_CONCURRENCY = 3;
6754
+ let phase4Done = 0;
6755
+ const remainingRequests = await pMap(
6756
+ remainingPages,
6757
+ async ({ name, id, route }) => {
6758
+ const prompt = [
6759
+ `Create ONE page called "${name}" at route "${route}".`,
6760
+ `Context: ${message}.`,
6761
+ `Generate complete pageCode for this single page only. Do not generate other pages.`,
6762
+ sharedNote,
6763
+ routeNote,
6764
+ alignmentNote,
6765
+ existingPagesContext,
6766
+ styleContext
6767
+ ].filter(Boolean).join("\n\n");
6768
+ try {
6769
+ const result = await parseModification(prompt, modCtx, provider, parseOpts);
6770
+ phase4Done++;
6771
+ spinner.text = `Phase 4/4 \u2014 ${phase4Done}/${remainingPages.length} pages generated...`;
6772
+ const codePage = result.requests.find((r) => r.type === "add-page");
6773
+ return codePage || { type: "add-page", target: "new", changes: { id, name, route } };
6774
+ } catch {
6775
+ phase4Done++;
6776
+ spinner.text = `Phase 4/4 \u2014 ${phase4Done}/${remainingPages.length} pages generated...`;
6777
+ return { type: "add-page", target: "new", changes: { id, name, route } };
6778
+ }
6779
+ },
6780
+ AI_CONCURRENCY
6781
+ );
6782
+ const allRequests = [homeRequest, ...remainingRequests];
6783
+ const emptyPages = allRequests.filter((r) => r.type === "add-page" && !r.changes?.pageCode);
6784
+ if (emptyPages.length > 0 && emptyPages.length <= 5) {
6785
+ spinner.text = `Retrying ${emptyPages.length} page(s) without code...`;
6786
+ for (const req of emptyPages) {
6787
+ const page = req.changes;
6788
+ const pageName = page.name || page.id || "page";
6789
+ const pageRoute = page.route || `/${pageName.toLowerCase()}`;
6790
+ try {
6791
+ const retryResult = await parseModification(
6792
+ `Create ONE page called "${pageName}" at route "${pageRoute}". Context: ${message}. Generate complete pageCode for this single page only.`,
6793
+ modCtx,
6794
+ provider,
6795
+ parseOpts
6796
+ );
6797
+ const codePage = retryResult.requests.find((r) => r.type === "add-page");
6798
+ if (codePage && codePage.changes?.pageCode) {
6799
+ const idx = allRequests.indexOf(req);
6800
+ if (idx !== -1) allRequests[idx] = codePage;
6801
+ }
6802
+ } catch {
6749
6803
  }
6750
6804
  }
6751
6805
  }
6752
- return issues;
6806
+ const withCode = allRequests.filter((r) => r.changes?.pageCode).length;
6807
+ spinner.succeed(`Phase 4/4 \u2014 Generated ${allRequests.length} pages (${withCode} with full code)`);
6808
+ return allRequests;
6753
6809
  }
6754
6810
 
6811
+ // src/commands/chat/modification-handler.ts
6812
+ import { resolve as resolve7 } from "path";
6813
+ import { mkdir as mkdir4 } from "fs/promises";
6814
+ import { dirname as dirname6 } from "path";
6815
+ import chalk11 from "chalk";
6816
+ import {
6817
+ getTemplateForPageType,
6818
+ loadManifest as loadManifest5,
6819
+ saveManifest,
6820
+ updateUsedIn,
6821
+ findSharedComponentByIdOrName,
6822
+ generateSharedComponent as generateSharedComponent3
6823
+ } from "@getcoherent/core";
6824
+
6755
6825
  // src/commands/chat/code-generator.ts
6756
6826
  import { resolve as resolve6 } from "path";
6757
6827
  import { existsSync as existsSync14 } from "fs";
@@ -7769,10 +7839,9 @@ async function applyModification(request, dsm, cm, pm, projectRoot, aiProvider,
7769
7839
  layoutShared: manifestForAudit.shared.filter((c) => c.type === "layout"),
7770
7840
  allShared: manifestForAudit.shared
7771
7841
  });
7772
- const validRoutes = dsm.getConfig().pages.map((p) => p.route);
7773
- const issues = validatePageQuality(codeToWrite, validRoutes);
7842
+ const issues = validatePageQuality(codeToWrite);
7774
7843
  const errors = issues.filter((i) => i.severity === "error");
7775
- if (errors.length >= 5 && aiProvider) {
7844
+ if (errors.length >= 2 && aiProvider) {
7776
7845
  console.log(
7777
7846
  chalk11.yellow(`
7778
7847
  \u{1F504} ${errors.length} quality errors \u2014 attempting AI fix for ${page.name || page.id}...`)
@@ -7791,7 +7860,7 @@ Rules:
7791
7860
  - Keep all existing functionality and layout intact`;
7792
7861
  const fixedCode2 = await ai.editPageCode(codeToWrite, instruction, page.name || page.id || "Page");
7793
7862
  if (fixedCode2 && fixedCode2.length > 100 && /export\s+default/.test(fixedCode2)) {
7794
- const recheck = validatePageQuality(fixedCode2, validRoutes);
7863
+ const recheck = validatePageQuality(fixedCode2);
7795
7864
  const recheckErrors = recheck.filter((i) => i.severity === "error");
7796
7865
  if (recheckErrors.length < errors.length) {
7797
7866
  codeToWrite = fixedCode2;
@@ -8479,18 +8548,18 @@ async function chatCommand(message, options) {
8479
8548
  );
8480
8549
  const preflightInstalledIds = [];
8481
8550
  const allNpmImportsFromPages = /* @__PURE__ */ new Set();
8551
+ const allNeededComponentIds = /* @__PURE__ */ new Set();
8482
8552
  for (const pageRequest of pageRequests) {
8483
8553
  const page = pageRequest.changes;
8484
- const neededComponentIds = /* @__PURE__ */ new Set();
8485
8554
  page.sections?.forEach(
8486
8555
  (section) => {
8487
8556
  if (section.componentId) {
8488
- neededComponentIds.add(section.componentId);
8557
+ allNeededComponentIds.add(section.componentId);
8489
8558
  }
8490
8559
  if (section.props?.fields && Array.isArray(section.props.fields)) {
8491
8560
  section.props.fields.forEach((field) => {
8492
8561
  if (field.component) {
8493
- neededComponentIds.add(field.component);
8562
+ allNeededComponentIds.add(field.component);
8494
8563
  }
8495
8564
  });
8496
8565
  }
@@ -8499,7 +8568,7 @@ async function chatCommand(message, options) {
8499
8568
  if (typeof page.pageCode === "string" && page.pageCode.trim() !== "") {
8500
8569
  const importMatches = page.pageCode.matchAll(/@\/components\/ui\/([a-z0-9-]+)/g);
8501
8570
  for (const m of importMatches) {
8502
- if (m[1]) neededComponentIds.add(m[1]);
8571
+ if (m[1]) allNeededComponentIds.add(m[1]);
8503
8572
  }
8504
8573
  extractNpmPackagesFromCode(page.pageCode).forEach((p) => allNpmImportsFromPages.add(p));
8505
8574
  }
@@ -8514,7 +8583,7 @@ async function chatCommand(message, options) {
8514
8583
  });
8515
8584
  const tmplImports = preview.matchAll(/@\/components\/ui\/([a-z0-9-]+)/g);
8516
8585
  for (const m of tmplImports) {
8517
- if (m[1]) neededComponentIds.add(m[1]);
8586
+ if (m[1]) allNeededComponentIds.add(m[1]);
8518
8587
  }
8519
8588
  extractNpmPackagesFromCode(preview).forEach((p) => allNpmImportsFromPages.add(p));
8520
8589
  } catch {
@@ -8522,8 +8591,8 @@ async function chatCommand(message, options) {
8522
8591
  }
8523
8592
  }
8524
8593
  if (DEBUG4) {
8525
- console.log(chalk13.gray("\n[DEBUG] Pre-flight analysis:"));
8526
- console.log(chalk13.gray(` Needed components: ${Array.from(neededComponentIds).join(", ")}`));
8594
+ console.log(chalk13.gray(`
8595
+ [DEBUG] Pre-flight analysis for page "${page.name || page.route}": `));
8527
8596
  console.log(chalk13.gray(` Page sections: ${page.sections?.length || 0}`));
8528
8597
  if (page.sections?.[0]?.props?.fields) {
8529
8598
  console.log(chalk13.gray(` First section has ${page.sections[0].props.fields.length} fields`));
@@ -8531,64 +8600,67 @@ async function chatCommand(message, options) {
8531
8600
  console.log(chalk13.gray(` Field ${i}: component=${f.component}`));
8532
8601
  });
8533
8602
  }
8534
- console.log("");
8535
8603
  }
8536
- const INVALID_COMPONENT_IDS = /* @__PURE__ */ new Set(["ui", "shared", "lib", "utils", "hooks", "app", "components"]);
8537
- for (const id of INVALID_COMPONENT_IDS) neededComponentIds.delete(id);
8538
- const missingComponents = [];
8539
- for (const componentId of neededComponentIds) {
8540
- const exists = cm.read(componentId);
8541
- if (DEBUG4) console.log(chalk13.gray(` Checking ${componentId}: ${exists ? "EXISTS" : "MISSING"}`));
8542
- if (!exists) {
8543
- missingComponents.push(componentId);
8544
- }
8604
+ }
8605
+ const INVALID_COMPONENT_IDS = /* @__PURE__ */ new Set(["ui", "shared", "lib", "utils", "hooks", "app", "components"]);
8606
+ for (const id of INVALID_COMPONENT_IDS) allNeededComponentIds.delete(id);
8607
+ if (DEBUG4) {
8608
+ console.log(chalk13.gray("\n[DEBUG] Pre-flight analysis (consolidated):"));
8609
+ console.log(chalk13.gray(` All needed components: ${Array.from(allNeededComponentIds).join(", ")}`));
8610
+ console.log("");
8611
+ }
8612
+ const missingComponents = [];
8613
+ for (const componentId of allNeededComponentIds) {
8614
+ const exists = cm.read(componentId);
8615
+ if (DEBUG4) console.log(chalk13.gray(` Checking ${componentId}: ${exists ? "EXISTS" : "MISSING"}`));
8616
+ if (!exists) {
8617
+ missingComponents.push(componentId);
8545
8618
  }
8546
- if (missingComponents.length > 0) {
8547
- spinner.stop();
8548
- console.log(chalk13.cyan("\n\u{1F50D} Pre-flight check: Installing missing components...\n"));
8549
- for (const componentId of missingComponents) {
8550
- if (DEBUG4) {
8551
- console.log(chalk13.gray(` [DEBUG] Trying to install: ${componentId}`));
8552
- console.log(chalk13.gray(` [DEBUG] isShadcnComponent(${componentId}): ${isShadcnComponent(componentId)}`));
8553
- }
8554
- if (isShadcnComponent(componentId)) {
8555
- try {
8556
- const shadcnDef = await installShadcnComponent(componentId, projectRoot);
8557
- if (DEBUG4)
8558
- console.log(chalk13.gray(` [DEBUG] shadcnDef for ${componentId}: ${shadcnDef ? "OK" : "NULL"}`));
8559
- if (shadcnDef) {
8560
- if (DEBUG4) console.log(chalk13.gray(` [DEBUG] Registering ${shadcnDef.id} (${shadcnDef.name})`));
8561
- const result = await cm.register(shadcnDef);
8562
- if (DEBUG4) {
8563
- console.log(
8564
- chalk13.gray(
8565
- ` [DEBUG] Register result: ${result.success ? "SUCCESS" : "FAILED"}${!result.success && result.message ? ` - ${result.message}` : ""}`
8566
- )
8567
- );
8568
- }
8569
- if (result.success) {
8570
- preflightInstalledIds.push(shadcnDef.id);
8571
- console.log(chalk13.green(` \u2728 Auto-installed ${shadcnDef.name} component`));
8572
- const updatedConfig2 = result.config;
8573
- dsm.updateConfig(updatedConfig2);
8574
- cm.updateConfig(updatedConfig2);
8575
- pm.updateConfig(updatedConfig2);
8576
- }
8619
+ }
8620
+ if (missingComponents.length > 0) {
8621
+ spinner.stop();
8622
+ console.log(chalk13.cyan("\n\u{1F50D} Pre-flight check: Installing missing components...\n"));
8623
+ for (const componentId of missingComponents) {
8624
+ if (DEBUG4) {
8625
+ console.log(chalk13.gray(` [DEBUG] Trying to install: ${componentId}`));
8626
+ console.log(chalk13.gray(` [DEBUG] isShadcnComponent(${componentId}): ${isShadcnComponent(componentId)}`));
8627
+ }
8628
+ if (isShadcnComponent(componentId)) {
8629
+ try {
8630
+ const shadcnDef = await installShadcnComponent(componentId, projectRoot);
8631
+ if (DEBUG4) console.log(chalk13.gray(` [DEBUG] shadcnDef for ${componentId}: ${shadcnDef ? "OK" : "NULL"}`));
8632
+ if (shadcnDef) {
8633
+ if (DEBUG4) console.log(chalk13.gray(` [DEBUG] Registering ${shadcnDef.id} (${shadcnDef.name})`));
8634
+ const result = await cm.register(shadcnDef);
8635
+ if (DEBUG4) {
8636
+ console.log(
8637
+ chalk13.gray(
8638
+ ` [DEBUG] Register result: ${result.success ? "SUCCESS" : "FAILED"}${!result.success && result.message ? ` - ${result.message}` : ""}`
8639
+ )
8640
+ );
8577
8641
  }
8578
- } catch (error) {
8579
- console.log(chalk13.red(` \u274C Failed to install ${componentId}:`));
8580
- console.log(chalk13.red(` ${error instanceof Error ? error.message : error}`));
8581
- if (error instanceof Error && error.stack) {
8582
- console.log(chalk13.gray(` ${error.stack.split("\n")[1]}`));
8642
+ if (result.success) {
8643
+ preflightInstalledIds.push(shadcnDef.id);
8644
+ console.log(chalk13.green(` \u2728 Auto-installed ${shadcnDef.name} component`));
8645
+ const updatedConfig2 = result.config;
8646
+ dsm.updateConfig(updatedConfig2);
8647
+ cm.updateConfig(updatedConfig2);
8648
+ pm.updateConfig(updatedConfig2);
8583
8649
  }
8584
8650
  }
8585
- } else {
8586
- console.log(chalk13.yellow(` \u26A0\uFE0F Component ${componentId} not available`));
8651
+ } catch (error) {
8652
+ console.log(chalk13.red(` \u274C Failed to install ${componentId}:`));
8653
+ console.log(chalk13.red(` ${error instanceof Error ? error.message : error}`));
8654
+ if (error instanceof Error && error.stack) {
8655
+ console.log(chalk13.gray(` ${error.stack.split("\n")[1]}`));
8656
+ }
8587
8657
  }
8658
+ } else {
8659
+ console.log(chalk13.yellow(` \u26A0\uFE0F Component ${componentId} not available`));
8588
8660
  }
8589
- console.log("");
8590
- spinner.start("Applying modifications...");
8591
8661
  }
8662
+ console.log("");
8663
+ spinner.start("Applying modifications...");
8592
8664
  }
8593
8665
  const installedPkgs = getInstalledPackages(projectRoot);
8594
8666
  const neededPkgs = /* @__PURE__ */ new Set([...COHERENT_REQUIRED_PACKAGES, ...allNpmImportsFromPages]);
@@ -8750,6 +8822,34 @@ async function chatCommand(message, options) {
8750
8822
  spinner.start("Finalizing...");
8751
8823
  }
8752
8824
  }
8825
+ const finalConfig = dsm.getConfig();
8826
+ const allRoutes = finalConfig.pages.map((p) => p.route).filter(Boolean);
8827
+ if (allRoutes.length > 1) {
8828
+ const linkIssues = [];
8829
+ for (const result of results) {
8830
+ if (!result.success) continue;
8831
+ for (const mod of result.modified) {
8832
+ if (mod.startsWith("app/") && mod.endsWith("/page.tsx")) {
8833
+ try {
8834
+ const code = readFileSync10(resolve9(projectRoot, mod), "utf-8");
8835
+ const issues = validatePageQuality(code, allRoutes).filter(
8836
+ (i) => i.type === "BROKEN_INTERNAL_LINK"
8837
+ );
8838
+ for (const issue of issues) {
8839
+ linkIssues.push({ page: mod, message: issue.message });
8840
+ }
8841
+ } catch {
8842
+ }
8843
+ }
8844
+ }
8845
+ }
8846
+ if (linkIssues.length > 0) {
8847
+ console.log(chalk13.yellow("\n\u{1F517} Broken internal links:"));
8848
+ for (const { page, message: message2 } of linkIssues) {
8849
+ console.log(chalk13.dim(` ${page}: ${message2}`));
8850
+ }
8851
+ }
8852
+ }
8753
8853
  const updatedConfig = dsm.getConfig();
8754
8854
  const darkMatch = /\bdark\s*(theme|mode|background)\b/i.test(message);
8755
8855
  const lightMatch = /\blight\s*(theme|mode|background)\b/i.test(message);