code-to-design 0.1.5 → 0.2.1

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.
@@ -1,10 +1,10 @@
1
1
  // src/commands/scan.ts
2
- import { join as join9 } from "path";
3
- import { rm, mkdir as mkdir3 } from "fs/promises";
2
+ import { join as join10 } from "path";
3
+ import { rm, mkdir as mkdir4 } from "fs/promises";
4
4
  import { existsSync as existsSync7, watch as fsWatch } from "fs";
5
5
 
6
6
  // ../core/src/discovery/route-scanner.ts
7
- import { readdir, stat } from "fs/promises";
7
+ import { readdir, readFile, stat } from "fs/promises";
8
8
  import { join, extname } from "path";
9
9
  var PAGE_EXTENSIONS = /* @__PURE__ */ new Set([".js", ".jsx", ".ts", ".tsx"]);
10
10
  var PAGE_BASENAMES = /* @__PURE__ */ new Set(["page"]);
@@ -13,6 +13,13 @@ function isPageFile(filename) {
13
13
  const basename = filename.slice(0, -ext.length);
14
14
  return PAGE_EXTENSIONS.has(ext) && PAGE_BASENAMES.has(basename);
15
15
  }
16
+ function isPagesRouterFile(filename) {
17
+ const ext = extname(filename);
18
+ if (!PAGE_EXTENSIONS.has(ext)) return false;
19
+ const basename = filename.slice(0, -ext.length);
20
+ if (basename.startsWith("_")) return false;
21
+ return true;
22
+ }
16
23
  function shouldSkipDir(name) {
17
24
  if (name.startsWith("_")) return true;
18
25
  if (name.startsWith("@")) return true;
@@ -82,24 +89,182 @@ async function scanDir(dirPath, urlSegments, params) {
82
89
  }
83
90
  return routes;
84
91
  }
92
+ async function scanPagesDir(dirPath, urlSegments, params) {
93
+ const routes = [];
94
+ let entries;
95
+ try {
96
+ entries = await readdir(dirPath);
97
+ } catch {
98
+ return routes;
99
+ }
100
+ for (const entry of entries) {
101
+ const entryPath = join(dirPath, entry);
102
+ const entryStat = await stat(entryPath).catch(() => null);
103
+ if (!entryStat) continue;
104
+ if (entryStat.isFile() && isPagesRouterFile(entry)) {
105
+ const ext = extname(entry);
106
+ const basename = entry.slice(0, -ext.length);
107
+ let fileUrlSegments;
108
+ let fileParams;
109
+ if (basename === "index") {
110
+ fileUrlSegments = urlSegments;
111
+ fileParams = params;
112
+ } else {
113
+ const param = parseDynamicSegment(basename);
114
+ const urlPart = segmentToUrlPart(basename);
115
+ fileUrlSegments = [...urlSegments, urlPart];
116
+ fileParams = param ? [...params, param] : params;
117
+ }
118
+ const urlPath = "/" + fileUrlSegments.join("/");
119
+ routes.push({
120
+ urlPath: urlPath || "/",
121
+ filePath: entryPath,
122
+ params: [...fileParams],
123
+ isDynamic: fileParams.length > 0
124
+ });
125
+ }
126
+ if (entryStat.isDirectory()) {
127
+ if (entry === "api") continue;
128
+ if (entry.startsWith("_")) continue;
129
+ if (entry === "node_modules" || entry === ".next") continue;
130
+ const param = parseDynamicSegment(entry);
131
+ const urlPart = segmentToUrlPart(entry);
132
+ const newParams = param ? [...params, param] : params;
133
+ const nested = await scanPagesDir(entryPath, [...urlSegments, urlPart], newParams);
134
+ routes.push(...nested);
135
+ }
136
+ }
137
+ return routes;
138
+ }
139
+ function parseReactRouterParam(segment) {
140
+ if (!segment.startsWith(":")) return null;
141
+ return { name: segment.slice(1), isCatchAll: false, isOptional: false };
142
+ }
143
+ async function findFiles(dir, extensions) {
144
+ const results = [];
145
+ let entries;
146
+ try {
147
+ entries = await readdir(dir);
148
+ } catch {
149
+ return results;
150
+ }
151
+ for (const entry of entries) {
152
+ const full = join(dir, entry);
153
+ const s = await stat(full).catch(() => null);
154
+ if (!s) continue;
155
+ if (s.isDirectory()) {
156
+ if (entry === "node_modules" || entry === ".git" || entry === "dist" || entry === "build") continue;
157
+ results.push(...await findFiles(full, extensions));
158
+ } else if (extensions.has(extname(entry))) {
159
+ results.push(full);
160
+ }
161
+ }
162
+ return results;
163
+ }
164
+ async function hasReactRouter(projectRoot) {
165
+ try {
166
+ const pkg = JSON.parse(await readFile(join(projectRoot, "package.json"), "utf-8"));
167
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
168
+ return "react-router-dom" in deps || "react-router" in deps;
169
+ } catch {
170
+ return false;
171
+ }
172
+ }
173
+ function extractRoutePathsFromSource(source) {
174
+ const paths = [];
175
+ const seen = /* @__PURE__ */ new Set();
176
+ const jsxPattern = /<Route\s[^>]*?path\s*=\s*["']([^"']+)["']/g;
177
+ let match;
178
+ while ((match = jsxPattern.exec(source)) !== null) {
179
+ const p = match[1];
180
+ if (p !== "*" && !seen.has(p)) {
181
+ seen.add(p);
182
+ paths.push(p);
183
+ }
184
+ }
185
+ const objPattern = /path\s*:\s*["']([^"']+)["']/g;
186
+ while ((match = objPattern.exec(source)) !== null) {
187
+ const p = match[1];
188
+ if (p !== "*" && !seen.has(p)) {
189
+ seen.add(p);
190
+ paths.push(p);
191
+ }
192
+ }
193
+ return paths;
194
+ }
195
+ async function scanReactRouter(projectRoot) {
196
+ const routes = [];
197
+ const seen = /* @__PURE__ */ new Set();
198
+ const srcDir = join(projectRoot, "src");
199
+ const scanRoot = await stat(srcDir).then(() => srcDir).catch(() => projectRoot);
200
+ const files = await findFiles(scanRoot, PAGE_EXTENSIONS);
201
+ for (const filePath of files) {
202
+ let source;
203
+ try {
204
+ source = await readFile(filePath, "utf-8");
205
+ } catch {
206
+ continue;
207
+ }
208
+ if (!source.includes("react-router-dom") && !source.includes("react-router")) {
209
+ continue;
210
+ }
211
+ if (!source.includes("Route") && !source.includes("createBrowserRouter") && !source.includes("createRoutesFromElements") && !source.match(/path\s*:\s*["']/)) {
212
+ continue;
213
+ }
214
+ const extractedPaths = extractRoutePathsFromSource(source);
215
+ for (const routePath of extractedPaths) {
216
+ if (seen.has(routePath)) continue;
217
+ seen.add(routePath);
218
+ const segments = routePath.split("/").filter(Boolean);
219
+ const params = [];
220
+ for (const seg of segments) {
221
+ const param = parseReactRouterParam(seg);
222
+ if (param) params.push(param);
223
+ }
224
+ const urlPath = routePath.startsWith("/") ? routePath : "/" + routePath;
225
+ routes.push({
226
+ urlPath,
227
+ filePath,
228
+ params,
229
+ isDynamic: params.length > 0
230
+ });
231
+ }
232
+ }
233
+ return routes;
234
+ }
85
235
  async function scanRoutes(options) {
86
- const { appDir } = options;
236
+ const { appDir, routerType = "auto" } = options;
237
+ if (routerType === "react-router") {
238
+ const routes2 = await scanReactRouter(appDir);
239
+ routes2.sort((a, b) => a.urlPath.localeCompare(b.urlPath));
240
+ return routes2;
241
+ }
242
+ if (routerType === "auto") {
243
+ const isReactRouter = await hasReactRouter(appDir);
244
+ if (isReactRouter) {
245
+ const routes2 = await scanReactRouter(appDir);
246
+ routes2.sort((a, b) => a.urlPath.localeCompare(b.urlPath));
247
+ return routes2;
248
+ }
249
+ }
87
250
  const dirStat = await stat(appDir).catch(() => null);
88
251
  if (!dirStat || !dirStat.isDirectory()) {
89
252
  throw new Error(`App directory not found: ${appDir}`);
90
253
  }
91
- const routes = await scanDir(appDir, [], []);
254
+ const dirName = appDir.replace(/\/$/, "").split("/").pop();
255
+ const isPagesRouter = routerType === "pages-router" || routerType === "auto" && dirName === "pages";
256
+ const routes = isPagesRouter ? await scanPagesDir(appDir, [], []) : await scanDir(appDir, [], []);
92
257
  routes.sort((a, b) => a.urlPath.localeCompare(b.urlPath));
93
258
  return routes;
94
259
  }
95
260
 
96
261
  // ../core/src/analysis/code-analyzer.ts
97
- import { readFile as readFile2 } from "fs/promises";
262
+ import { readFile as readFile3 } from "fs/promises";
98
263
  import { join as join3, dirname, resolve } from "path";
99
264
  import { existsSync as existsSync2 } from "fs";
100
265
 
101
266
  // ../core/src/analysis/auth-detector.ts
102
- import { readFile } from "fs/promises";
267
+ import { readFile as readFile2 } from "fs/promises";
103
268
  import { join as join2 } from "path";
104
269
  import { existsSync } from "fs";
105
270
  var MIDDLEWARE_FILES = ["middleware.ts", "middleware.js", "middleware.tsx", "middleware.jsx"];
@@ -147,7 +312,7 @@ async function detectAuth(projectRoot, allSources) {
147
312
  const middlewarePath = join2(projectRoot, filename);
148
313
  if (existsSync(middlewarePath)) {
149
314
  try {
150
- const source = await readFile(middlewarePath, "utf-8");
315
+ const source = await readFile2(middlewarePath, "utf-8");
151
316
  const cookies = extractCookieNames(source);
152
317
  if (cookies.length > 0) {
153
318
  config.hasAuth = true;
@@ -170,6 +335,84 @@ async function detectAuth(projectRoot, allSources) {
170
335
  return config;
171
336
  }
172
337
 
338
+ // ../core/src/analysis/endpoint-extractor.ts
339
+ function extractBaseUrl(source) {
340
+ const envFallback = source.match(
341
+ /process\.env\.\w+\s*\|\|\s*['"]([^'"]+)['"]/
342
+ );
343
+ if (envFallback) return envFallback[1];
344
+ const baseUrlProp = source.match(/baseURL\s*:\s*['"]([^'"]+)['"]/);
345
+ if (baseUrlProp) return baseUrlProp[1];
346
+ const constAssign = source.match(
347
+ /(?:API_BASE_URL|API_URL|BASE_URL)\s*=\s*['"]([^'"]+)['"]/
348
+ );
349
+ if (constAssign) return constAssign[1];
350
+ return null;
351
+ }
352
+ function extractEndpoints(apiClientSource) {
353
+ const endpoints = [];
354
+ const seen = /* @__PURE__ */ new Set();
355
+ const lines = apiClientSource.split("\n");
356
+ for (let i = 0; i < lines.length; i++) {
357
+ const line = lines[i];
358
+ const methodCallRegex = /\.\s*(get|post|put|delete|patch)\s*\(\s*(?:['"`])([^'"`$]*)/gi;
359
+ let match;
360
+ while ((match = methodCallRegex.exec(line)) !== null) {
361
+ const method = match[1].toUpperCase();
362
+ let path = match[2];
363
+ path = path.replace(/['"`)]+$/, "").trim();
364
+ if (!path || path.length === 0) continue;
365
+ const key = `${method} ${path}`;
366
+ if (seen.has(key)) continue;
367
+ seen.add(key);
368
+ const endpoint = { method, path };
369
+ const fnName = findFunctionName(lines, i);
370
+ if (fnName) endpoint.functionName = fnName;
371
+ endpoints.push(endpoint);
372
+ }
373
+ const fetchRegex = /\bfetch\s*\(\s*['"`]([^'"`$]+)/g;
374
+ while ((match = fetchRegex.exec(line)) !== null) {
375
+ const path = match[1].replace(/['"`)]+$/, "").trim();
376
+ if (!path || path.length === 0) continue;
377
+ const key = `GET ${path}`;
378
+ if (seen.has(key)) continue;
379
+ seen.add(key);
380
+ const endpoint = { method: "GET", path };
381
+ const fnName = findFunctionName(lines, i);
382
+ if (fnName) endpoint.functionName = fnName;
383
+ endpoints.push(endpoint);
384
+ }
385
+ const templateRegex = /\.\s*(get|post|put|delete|patch)\s*\(\s*`([^`]*?)\$\{/gi;
386
+ while ((match = templateRegex.exec(line)) !== null) {
387
+ const method = match[1].toUpperCase();
388
+ const path = match[2].trim();
389
+ if (!path || path.length === 0) continue;
390
+ const key = `${method} ${path}`;
391
+ if (seen.has(key)) continue;
392
+ seen.add(key);
393
+ const endpoint = { method, path };
394
+ const fnName = findFunctionName(lines, i);
395
+ if (fnName) endpoint.functionName = fnName;
396
+ endpoints.push(endpoint);
397
+ }
398
+ }
399
+ return endpoints;
400
+ }
401
+ function findFunctionName(lines, lineIndex) {
402
+ for (let i = lineIndex; i >= Math.max(0, lineIndex - 3); i--) {
403
+ const line = lines[i];
404
+ const constArrow = line.match(/(?:export\s+)?(?:const|let|var)\s+(\w+)\s*=\s*(?:\([^)]*\)\s*=>|\([^)]*\)\s*:\s*\w[^=]*=>)/);
405
+ if (constArrow) return constArrow[1];
406
+ const propMatch = line.match(/(\w+)\s*:\s*(?:\([^)]*\)\s*=>|function\b)/);
407
+ if (propMatch) return propMatch[1];
408
+ const fnMatch = line.match(/(?:async\s+)?function\s+(\w+)/);
409
+ if (fnMatch) return fnMatch[1];
410
+ const methodMatch = line.match(/^\s*(\w+)\s*\([^)]*\)\s*\{/);
411
+ if (methodMatch) return methodMatch[1];
412
+ }
413
+ return void 0;
414
+ }
415
+
173
416
  // ../core/src/analysis/code-analyzer.ts
174
417
  var DEFAULT_MAX_TOKENS = 8e3;
175
418
  var CHARS_PER_TOKEN = 4;
@@ -198,7 +441,7 @@ async function readPathAliases(projectRoot) {
198
441
  const configPath = join3(projectRoot, filename);
199
442
  if (!existsSync2(configPath)) continue;
200
443
  try {
201
- const content = await readFile2(configPath, "utf-8");
444
+ const content = await readFile3(configPath, "utf-8");
202
445
  const stripped = content.replace(
203
446
  /"(?:[^"\\]|\\.)*"|\/\/.*$|\/\*[\s\S]*?\*\//gm,
204
447
  (match) => match.startsWith('"') ? match : ""
@@ -242,7 +485,7 @@ async function traceImports(filePath, projectRoot, aliases, maxDepth, visited =
242
485
  visited.add(filePath);
243
486
  let source;
244
487
  try {
245
- source = await readFile2(filePath, "utf-8");
488
+ source = await readFile3(filePath, "utf-8");
246
489
  } catch {
247
490
  return results;
248
491
  }
@@ -289,6 +532,58 @@ function estimateTokens(text) {
289
532
  function hasApiCalls(source) {
290
533
  return /\bfetch\s*\(/.test(source) || /\baxios\b/.test(source) || /\buseQuery\b/.test(source) || /\buseMutation\b/.test(source) || /\bapiRequest\b/.test(source) || /\bgetServerSideProps\b/.test(source) || /\bgetStaticProps\b/.test(source) || /API_BASE_URL/.test(source) || /['"]\/api\//.test(source) || /from\s+['"].*api[-_]?client.*['"]/.test(source) || /from\s+['"].*\/api['"]/.test(source) || /from\s+['"].*\/services\//.test(source);
291
534
  }
535
+ var API_CLIENT_CANDIDATES = [
536
+ "lib/api.ts",
537
+ "lib/api.tsx",
538
+ "lib/api.js",
539
+ "lib/api.jsx",
540
+ "lib/api-client.ts",
541
+ "lib/api-client.tsx",
542
+ "lib/api-client.js",
543
+ "lib/api-client.jsx",
544
+ "src/lib/api.ts",
545
+ "src/lib/api.tsx",
546
+ "src/lib/api.js",
547
+ "src/lib/api.jsx",
548
+ "src/lib/api-client.ts",
549
+ "src/lib/api-client.tsx",
550
+ "src/lib/api-client.js",
551
+ "src/lib/api-client.jsx",
552
+ "services/api.ts",
553
+ "services/api.js",
554
+ "services/api.tsx",
555
+ "services/api.jsx",
556
+ "src/services/api.ts",
557
+ "src/services/api.js",
558
+ "src/services/api.tsx",
559
+ "src/services/api.jsx",
560
+ "utils/api.ts",
561
+ "utils/api.js",
562
+ "utils/api.tsx",
563
+ "utils/api.jsx",
564
+ "src/utils/api.ts",
565
+ "src/utils/api.js",
566
+ "src/utils/api.tsx",
567
+ "src/utils/api.jsx"
568
+ ];
569
+ function hasApiClientPatterns(source) {
570
+ return /\.(get|post|put|delete|patch)\s*\(/.test(source) || /\bfetch\s*\(/.test(source) || /\baxios\b/.test(source) || /\bbaseURL\b/.test(source) || /\bAPI_URL\b/.test(source);
571
+ }
572
+ async function findApiClientEagerly(projectRoot, alreadyIncluded) {
573
+ for (const candidate of API_CLIENT_CANDIDATES) {
574
+ const fullPath = join3(projectRoot, candidate);
575
+ if (alreadyIncluded.has(fullPath)) continue;
576
+ if (!existsSync2(fullPath)) continue;
577
+ try {
578
+ const content = await readFile3(fullPath, "utf-8");
579
+ if (hasApiClientPatterns(content)) {
580
+ return { path: fullPath, content };
581
+ }
582
+ } catch {
583
+ }
584
+ }
585
+ return null;
586
+ }
292
587
  async function analyzePage(route, options) {
293
588
  const { projectRoot, maxTokensPerPage = DEFAULT_MAX_TOKENS } = options;
294
589
  const maxChars = maxTokensPerPage * CHARS_PER_TOKEN;
@@ -308,6 +603,39 @@ ${content}
308
603
  }
309
604
  sourceContext += section;
310
605
  }
606
+ const alreadyIncluded = new Set(tracedFiles.keys());
607
+ let apiClientPath = null;
608
+ let apiClientSource = null;
609
+ const eagerResult = await findApiClientEagerly(projectRoot, alreadyIncluded);
610
+ if (eagerResult) {
611
+ apiClientPath = eagerResult.path;
612
+ apiClientSource = eagerResult.content;
613
+ const relativePath = eagerResult.path.startsWith(projectRoot) ? eagerResult.path.slice(projectRoot.length + 1) : eagerResult.path;
614
+ const section = `
615
+ // === ${relativePath} ===
616
+ ${eagerResult.content}
617
+ `;
618
+ if (sourceContext.length + section.length <= maxChars) {
619
+ sourceContext += section;
620
+ }
621
+ resolvedImports.push(eagerResult.path);
622
+ allSources.push(eagerResult.content);
623
+ } else {
624
+ for (const [filePath, content] of tracedFiles) {
625
+ if (filePath === route.filePath) continue;
626
+ if (hasApiClientPatterns(content)) {
627
+ apiClientPath = filePath;
628
+ apiClientSource = content;
629
+ break;
630
+ }
631
+ }
632
+ }
633
+ let extractedEndpointsList = [];
634
+ let apiBaseUrl = null;
635
+ if (apiClientSource) {
636
+ extractedEndpointsList = extractEndpoints(apiClientSource);
637
+ apiBaseUrl = extractBaseUrl(apiClientSource);
638
+ }
311
639
  const authConfig = await detectAuth(projectRoot, allSources);
312
640
  const combinedSource = allSources.join("\n");
313
641
  const hasApi = hasApiCalls(sourceContext) || hasApiCalls(combinedSource);
@@ -325,13 +653,13 @@ ${content}
325
653
  unresolvedImports: [...new Set(unresolvedImports)],
326
654
  authConfig,
327
655
  hasApiDependencies: hasApi,
328
- estimatedTokens: estimateTokens(sourceContext)
656
+ estimatedTokens: estimateTokens(sourceContext),
657
+ apiClientPath,
658
+ extractedEndpoints: extractedEndpointsList,
659
+ apiBaseUrl
329
660
  };
330
661
  }
331
662
 
332
- // ../core/src/mock/types.ts
333
- var ALL_STATE_VARIANTS = ["success", "empty", "error", "loading"];
334
-
335
663
  // ../core/src/mock/llm-client.ts
336
664
  import Anthropic from "@anthropic-ai/sdk";
337
665
  var LlmClient = class {
@@ -359,20 +687,18 @@ var LlmClient = class {
359
687
  };
360
688
 
361
689
  // ../core/src/mock/prompt-templates.ts
362
- var SYSTEM_PROMPT = `You are a mock data generator for a UI pre-rendering tool called VibeCanvas.
363
- Your job is to analyze a Next.js page's source code and generate realistic mock API responses that will make the page render correctly.
690
+ var SYSTEM_PROMPT = `You are a mock data generator for a UI pre-rendering tool called Code to Design.
691
+ Your job is to analyze a Next.js page's source code and generate realistic mock API responses that will make the page render correctly in different visual states.
364
692
 
365
693
  Rules:
366
694
  1. Output ONLY valid JSON \u2014 no markdown, no code fences, no explanation.
367
- 2. Mock data must match the TypeScript interfaces found in the source code.
695
+ 2. Mock data must match the TypeScript interfaces and API response shapes found in the source code.
368
696
  3. Generate contextually appropriate data (e.g., real company names for a finance app, realistic usernames for a social app).
369
- 4. For each API endpoint, generate mock responses for these state variants:
370
- - "success": realistic data with multiple items
371
- - "empty": empty collections, zero counts
372
- - "error": API returns HTTP 500 with error message
373
- - "loading": same as success but with a 3000ms delay
374
- 5. Include auth mock data if the page requires authentication.
375
- 6. For dynamic route parameters, generate a realistic sample value.`;
697
+ 4. Generate MULTIPLE items in success data (at least 3-5 items for list endpoints).
698
+ 5. If actual API endpoint URLs are provided, use those EXACT paths in your response.
699
+ 6. Analyze the page's conditional rendering (if/else, ternary, switch, state variables) to identify meaningful visual states \u2014 not just generic success/error.
700
+ 7. Include auth mock data if the page requires authentication.
701
+ 8. For dynamic route parameters, generate a realistic sample value.`;
376
702
  function buildUserPrompt(analysis) {
377
703
  const parts = [];
378
704
  parts.push(`## Page Route: ${analysis.route.urlPath}`);
@@ -380,6 +706,16 @@ function buildUserPrompt(analysis) {
380
706
  if (analysis.route.isDynamic) {
381
707
  parts.push(`## Dynamic Parameters: ${analysis.route.params.map((p) => p.name).join(", ")}`);
382
708
  }
709
+ if (analysis.extractedEndpoints.length > 0) {
710
+ parts.push("## Actual API Endpoints (use these EXACT paths)");
711
+ for (const ep of analysis.extractedEndpoints) {
712
+ const name = ep.functionName ? ` (${ep.functionName})` : "";
713
+ parts.push(`- ${ep.method} ${ep.path}${name}`);
714
+ }
715
+ if (analysis.apiBaseUrl) {
716
+ parts.push(`- Base URL: ${analysis.apiBaseUrl}`);
717
+ }
718
+ }
383
719
  parts.push("## Source Code Context");
384
720
  parts.push(analysis.sourceContext);
385
721
  if (analysis.authConfig.hasAuth) {
@@ -398,29 +734,51 @@ function buildUserPrompt(analysis) {
398
734
  parts.push(`
399
735
  ## Required Output Format
400
736
 
737
+ Analyze the page code carefully. Look at:
738
+ - Conditional rendering (if/else, ternary operators, switch statements)
739
+ - State variables that control what is displayed
740
+ - URL parameters or search params that affect the view
741
+ - Authentication state checks
742
+ - Data loading patterns
743
+
744
+ Then generate mock data for **page-specific visual states** \u2014 not just generic success/error. For example:
745
+ - A dashboard with tabs might have states: "overview_tab", "readings_tab", "settings_tab"
746
+ - A page checking auth might have: "authenticated_with_data", "authenticated_empty", "unauthenticated"
747
+ - A results page might have: "results_found", "no_results", "invalid_id"
748
+
749
+ Always include at least these base states:
750
+ - One state with realistic populated data (the "happy path")
751
+ - One state with empty/no data
752
+ - One error state (API returns 500)
753
+
401
754
  Return a JSON object with this exact structure:
402
755
  {
403
- "routeParams": { "paramName": "sampleValue" }, // only if dynamic route
404
- "apiEndpoints": [
756
+ "routeParams": { "paramName": "sampleValue" },
757
+ "stateVariants": [
405
758
  {
406
- "urlPattern": "/api/endpoint", // the URL path pattern to intercept
407
- "method": "GET", // HTTP method
408
- "states": {
409
- "success": { "status": 200, "body": { ... } },
410
- "empty": { "status": 200, "body": { ... } },
411
- "error": { "status": 500, "body": { "detail": "Internal server error" } },
412
- "loading": { "status": 200, "body": { ... }, "delay": 3000 }
759
+ "name": "descriptive_state_name",
760
+ "description": "What this state represents visually",
761
+ "apiMocks": {
762
+ "METHOD /endpoint/path": {
763
+ "status": 200,
764
+ "body": { ... },
765
+ "delay": 0
766
+ }
413
767
  }
414
768
  }
415
769
  ],
416
- "authMock": { // only if auth is required
770
+ "authMock": {
417
771
  "cookies": { "cookie_name": "mock_value" },
418
772
  "authCheckEndpoint": "/auth/me",
419
773
  "authCheckResponse": { "status": 200, "body": { ... } }
420
774
  }
421
775
  }
422
776
 
423
- Generate ONLY the JSON. No explanation, no markdown fences.`);
777
+ IMPORTANT:
778
+ - Use the ACTUAL endpoint paths from the "Actual API Endpoints" section above if provided.
779
+ - Each state variant should produce a VISUALLY DIFFERENT page render.
780
+ - Generate at least 3-5 items in list/array responses for the happy path state.
781
+ - Generate ONLY the JSON. No explanation, no markdown fences.`);
424
782
  return parts.join("\n\n");
425
783
  }
426
784
 
@@ -444,7 +802,41 @@ function parseLlmJson(content) {
444
802
  return null;
445
803
  }
446
804
  }
447
- function convertToMockConfigs(output, analysis, variants) {
805
+ function isV2Output(output) {
806
+ return "stateVariants" in output && Array.isArray(output.stateVariants);
807
+ }
808
+ function convertV2ToMockConfigs(output, analysis) {
809
+ if (!output.stateVariants || output.stateVariants.length === 0) {
810
+ return [];
811
+ }
812
+ return output.stateVariants.map((variant) => {
813
+ const apiMocks = {};
814
+ for (const [pattern, mock] of Object.entries(variant.apiMocks)) {
815
+ apiMocks[pattern] = {
816
+ status: mock.status,
817
+ body: mock.body,
818
+ delay: mock.delay
819
+ };
820
+ }
821
+ let authMock = null;
822
+ if (analysis.authConfig.hasAuth && output.authMock) {
823
+ authMock = {
824
+ cookies: output.authMock.cookies,
825
+ authCheckResponse: {
826
+ status: output.authMock.authCheckResponse.status,
827
+ body: output.authMock.authCheckResponse.body
828
+ }
829
+ };
830
+ }
831
+ return {
832
+ stateName: variant.name,
833
+ apiMocks,
834
+ authMock,
835
+ routeParams: output.routeParams
836
+ };
837
+ });
838
+ }
839
+ function convertV1ToMockConfigs(output, analysis, variants) {
448
840
  const configs = [];
449
841
  for (const variant of variants) {
450
842
  const apiMocks = {};
@@ -494,7 +886,7 @@ function generateFallbackConfigs(analysis, variants) {
494
886
  }));
495
887
  }
496
888
  async function generateMocks(analysis, options) {
497
- const variants = options.variants ?? ALL_STATE_VARIANTS;
889
+ const variants = options.variants ?? ["success", "empty", "error", "loading"];
498
890
  if (!analysis.hasApiDependencies) {
499
891
  return {
500
892
  configs: generateFallbackConfigs(analysis, variants),
@@ -513,7 +905,15 @@ async function generateMocks(analysis, options) {
513
905
  tokenUsage: { input: response.inputTokens, output: response.outputTokens }
514
906
  };
515
907
  }
516
- const configs = convertToMockConfigs(parsed, analysis, variants);
908
+ let configs;
909
+ if (isV2Output(parsed)) {
910
+ configs = convertV2ToMockConfigs(parsed, analysis);
911
+ } else {
912
+ configs = convertV1ToMockConfigs(parsed, analysis, variants);
913
+ }
914
+ if (configs.length === 0) {
915
+ configs = generateFallbackConfigs(analysis, variants);
916
+ }
517
917
  return {
518
918
  configs,
519
919
  tokenUsage: { input: response.inputTokens, output: response.outputTokens }
@@ -529,8 +929,8 @@ async function generateMocks(analysis, options) {
529
929
 
530
930
  // ../core/src/render/pre-renderer.ts
531
931
  import { chromium } from "playwright";
532
- import { mkdir, writeFile } from "fs/promises";
533
- import { join as join5 } from "path";
932
+ import { mkdir as mkdir2, writeFile as writeFile2 } from "fs/promises";
933
+ import { join as join6 } from "path";
534
934
 
535
935
  // ../core/src/render/dev-server.ts
536
936
  import { spawn } from "child_process";
@@ -633,6 +1033,199 @@ ${stderr.slice(-500)}`));
633
1033
  };
634
1034
  }
635
1035
 
1036
+ // ../core/src/render/style-inliner.ts
1037
+ async function inlineStylesAndCleanup(page) {
1038
+ await page.evaluate(`(() => {
1039
+ const links = document.querySelectorAll('link[rel="stylesheet"]');
1040
+ links.forEach(link => {
1041
+ try {
1042
+ const href = link.getAttribute('href');
1043
+ if (!href) return;
1044
+ for (const sheet of document.styleSheets) {
1045
+ if (sheet.href && sheet.href.includes(href.replace(/^\\//, ''))) {
1046
+ const rules = Array.from(sheet.cssRules).map(r => r.cssText).join('\\n');
1047
+ const style = document.createElement('style');
1048
+ style.textContent = rules;
1049
+ link.parentNode.replaceChild(style, link);
1050
+ break;
1051
+ }
1052
+ }
1053
+ } catch (e) {}
1054
+ });
1055
+ document.querySelectorAll('script').forEach(s => s.remove());
1056
+ })()`);
1057
+ }
1058
+
1059
+ // ../core/src/render/interaction-capturer.ts
1060
+ import { mkdir, writeFile } from "fs/promises";
1061
+ import { join as join5 } from "path";
1062
+ function slugifyRoute(urlPath) {
1063
+ if (urlPath === "/") return "index";
1064
+ return urlPath.replace(/^\//, "").replace(/\//g, "-").replace(/:/g, "_").replace(/[^a-zA-Z0-9_-]/g, "_");
1065
+ }
1066
+ async function findClickableElements(page, maxElements) {
1067
+ return page.evaluate(`((max) => {
1068
+ const selectors = [
1069
+ '[role="tab"]:not([aria-selected="true"]):not([aria-disabled="true"])',
1070
+ '[role="button"]:not([aria-disabled="true"]):not([disabled])',
1071
+ 'button:not([disabled]):not([type="submit"])',
1072
+ '[data-tab]:not(.active):not(.selected)',
1073
+ '.tab:not(.active):not(.selected)',
1074
+ 'a[href="#"]:not(.active)',
1075
+ 'a[href^="#"]:not([href="#"]):not(.active)',
1076
+ ];
1077
+
1078
+ const seen = new Set();
1079
+ const results = [];
1080
+
1081
+ for (const sel of selectors) {
1082
+ if (results.length >= max) break;
1083
+ const elements = document.querySelectorAll(sel);
1084
+
1085
+ for (const el of elements) {
1086
+ if (results.length >= max) break;
1087
+ if (seen.has(el)) continue;
1088
+ seen.add(el);
1089
+
1090
+ const rect = el.getBoundingClientRect();
1091
+ if (rect.width === 0 || rect.height === 0) continue;
1092
+ const style = window.getComputedStyle(el);
1093
+ if (style.display === 'none' || style.visibility === 'hidden') continue;
1094
+
1095
+ if (el.tagName === 'A') {
1096
+ const href = el.getAttribute('href') || '';
1097
+ if (href.startsWith('http') || href.startsWith('//')) continue;
1098
+ }
1099
+
1100
+ if (el.tagName === 'BUTTON' && el.type === 'submit') continue;
1101
+
1102
+ const text = (el.textContent || '').trim().slice(0, 50);
1103
+ const ariaLabel = el.getAttribute('aria-label');
1104
+ const role = el.getAttribute('role');
1105
+ const tag = el.tagName.toLowerCase();
1106
+
1107
+ let desc = '';
1108
+ if (role === 'tab') desc = 'Tab: ' + (ariaLabel || text || 'unnamed');
1109
+ else if (role === 'button') desc = 'Button: ' + (ariaLabel || text || 'unnamed');
1110
+ else if (tag === 'button') desc = 'Button: ' + (ariaLabel || text || 'unnamed');
1111
+ else desc = 'Clickable: ' + (ariaLabel || text || 'unnamed');
1112
+
1113
+ let uniqueSelector = '';
1114
+ const id = el.getAttribute('id');
1115
+ if (id) {
1116
+ uniqueSelector = '#' + CSS.escape(id);
1117
+ } else {
1118
+ const dataTestId = el.getAttribute('data-testid');
1119
+ if (dataTestId) {
1120
+ uniqueSelector = '[data-testid="' + CSS.escape(dataTestId) + '"]';
1121
+ } else {
1122
+ const allMatching = document.querySelectorAll(sel);
1123
+ const idx = Array.from(allMatching).indexOf(el);
1124
+ uniqueSelector = '__INDEX__' + sel + '__' + idx;
1125
+ }
1126
+ }
1127
+
1128
+ results.push({ selector: uniqueSelector, description: desc });
1129
+ }
1130
+ }
1131
+
1132
+ return results;
1133
+ })(${maxElements})`);
1134
+ }
1135
+ async function captureInteractions(page, pageUrl, route, stateName, outputDir, options) {
1136
+ const maxInteractions = options?.maxInteractions ?? 5;
1137
+ const settleTime = options?.settleTime ?? 500;
1138
+ const routeSlug = slugifyRoute(route.urlPath);
1139
+ const stateDir = join5(outputDir, "renders", routeSlug);
1140
+ await mkdir(stateDir, { recursive: true });
1141
+ const clickables = await findClickableElements(page, maxInteractions);
1142
+ if (clickables.length === 0) return [];
1143
+ const results = [];
1144
+ for (let i = 0; i < clickables.length; i++) {
1145
+ const clickable = clickables[i];
1146
+ const htmlRelPath = join5("renders", routeSlug, `${stateName}_interaction_${i}.html`);
1147
+ const htmlAbsPath = join5(outputDir, htmlRelPath);
1148
+ try {
1149
+ let clicked = false;
1150
+ if (clickable.selector.startsWith("__INDEX__")) {
1151
+ const parts = clickable.selector.slice("__INDEX__".length);
1152
+ const lastUnderscoreIdx = parts.lastIndexOf("__");
1153
+ const sel = parts.slice(0, lastUnderscoreIdx);
1154
+ const idx = parseInt(parts.slice(lastUnderscoreIdx + 2), 10);
1155
+ const elements = await page.$$(sel);
1156
+ if (elements[idx]) {
1157
+ await elements[idx].click();
1158
+ clicked = true;
1159
+ }
1160
+ } else {
1161
+ const element = await page.$(clickable.selector);
1162
+ if (element) {
1163
+ await element.click();
1164
+ clicked = true;
1165
+ }
1166
+ }
1167
+ if (!clicked) {
1168
+ results.push({
1169
+ elementDescription: clickable.description,
1170
+ htmlPath: htmlRelPath,
1171
+ success: false,
1172
+ error: "Element not found on re-query"
1173
+ });
1174
+ continue;
1175
+ }
1176
+ await page.waitForTimeout(settleTime);
1177
+ await inlineStylesAndCleanup(page);
1178
+ const html = await page.content();
1179
+ await writeFile(htmlAbsPath, html, "utf-8");
1180
+ results.push({
1181
+ elementDescription: clickable.description,
1182
+ htmlPath: htmlRelPath,
1183
+ success: true
1184
+ });
1185
+ } catch (err) {
1186
+ results.push({
1187
+ elementDescription: clickable.description,
1188
+ htmlPath: htmlRelPath,
1189
+ success: false,
1190
+ error: err instanceof Error ? err.message : String(err)
1191
+ });
1192
+ }
1193
+ try {
1194
+ await page.goto(pageUrl, { waitUntil: "networkidle", timeout: 1e4 });
1195
+ await page.waitForTimeout(settleTime);
1196
+ } catch {
1197
+ break;
1198
+ }
1199
+ }
1200
+ return results;
1201
+ }
1202
+
1203
+ // ../core/src/render/url-matcher.ts
1204
+ function matchMockUrl(requestUrl, requestMethod, mockPattern) {
1205
+ const [mockMethod, ...pathParts] = mockPattern.split(" ");
1206
+ if (requestMethod !== mockMethod) return false;
1207
+ let mockPath = pathParts.join(" ");
1208
+ mockPath = mockPath.replace(/\/+$/, "");
1209
+ const urlPath = new URL(requestUrl).pathname.replace(/\/+$/, "");
1210
+ const regexStr = mockPath.replace(/\{[^}]+\}/g, "[^/]+").replace(/\*/g, "[^/]+").replace(/:[a-zA-Z_]+/g, "[^/]+");
1211
+ const fullRegex = new RegExp("^" + regexStr.replace(/\//g, "\\/") + "$");
1212
+ if (fullRegex.test(urlPath)) return true;
1213
+ const mockSegments = mockPath.split("/").filter(Boolean);
1214
+ const urlSegments = urlPath.split("/").filter(Boolean);
1215
+ if (mockSegments.length >= 1 && urlSegments.length >= mockSegments.length) {
1216
+ const urlSuffix = urlSegments.slice(-mockSegments.length);
1217
+ const match = mockSegments.every((seg, i) => {
1218
+ if (seg.startsWith("{") || seg.startsWith(":") || seg === "*") return true;
1219
+ return seg === urlSuffix[i];
1220
+ });
1221
+ if (match) return true;
1222
+ }
1223
+ return false;
1224
+ }
1225
+ function isApiUrl(url) {
1226
+ return url.includes("/api/") || url.includes(":8000") || url.includes(":3000/api");
1227
+ }
1228
+
636
1229
  // ../core/src/render/pre-renderer.ts
637
1230
  var DEFAULT_CONCURRENCY = 3;
638
1231
  var DEFAULT_PAGE_TIMEOUT = 15e3;
@@ -650,7 +1243,7 @@ function buildUrlPath(route, mockConfig) {
650
1243
  }
651
1244
  return path;
652
1245
  }
653
- function slugifyRoute(urlPath) {
1246
+ function slugifyRoute2(urlPath) {
654
1247
  if (urlPath === "/") return "index";
655
1248
  return urlPath.replace(/^\//, "").replace(/\//g, "-").replace(/:/g, "_").replace(/[^a-zA-Z0-9_-]/g, "_");
656
1249
  }
@@ -688,12 +1281,7 @@ async function setupMockInterception(page, devServerUrl, mockConfig, authConfig)
688
1281
  });
689
1282
  }
690
1283
  for (const [pattern, mock] of Object.entries(mockConfig.apiMocks)) {
691
- const [mockMethod, ...pathParts] = pattern.split(" ");
692
- let mockPath = pathParts.join(" ");
693
- mockPath = mockPath.replace(/\{[^}]+\}/g, "[^/]+");
694
- mockPath = mockPath.replace(/\*/g, "[^/]+");
695
- const pathRegex = new RegExp(mockPath.replace(/\//g, "\\/"));
696
- if (method === mockMethod && pathRegex.test(url)) {
1284
+ if (matchMockUrl(url, method, pattern)) {
697
1285
  if (mock.delay) await new Promise((r) => setTimeout(r, mock.delay));
698
1286
  return route.fulfill({
699
1287
  status: mock.status,
@@ -702,19 +1290,26 @@ async function setupMockInterception(page, devServerUrl, mockConfig, authConfig)
702
1290
  });
703
1291
  }
704
1292
  }
1293
+ if (isApiUrl(url)) {
1294
+ return route.fulfill({
1295
+ status: 200,
1296
+ contentType: "application/json",
1297
+ body: JSON.stringify({ data: [], message: "ok" })
1298
+ });
1299
+ }
705
1300
  return route.continue();
706
1301
  });
707
1302
  }
708
1303
  async function renderPage(context, devServerUrl, task, outputDir, options, viewport) {
709
1304
  const { route, mockConfig, authConfig } = task;
710
- const routeSlug = slugifyRoute(route.urlPath);
711
- const stateDir = join5(outputDir, "renders", routeSlug);
712
- await mkdir(stateDir, { recursive: true });
1305
+ const routeSlug = slugifyRoute2(route.urlPath);
1306
+ const stateDir = join6(outputDir, "renders", routeSlug);
1307
+ await mkdir2(stateDir, { recursive: true });
713
1308
  const filePrefix = viewport ? `${mockConfig.stateName}_${viewport.name}` : mockConfig.stateName;
714
- const htmlRelPath = join5("renders", routeSlug, `${filePrefix}.html`);
715
- const pngRelPath = join5("renders", routeSlug, `${filePrefix}.png`);
716
- const htmlAbsPath = join5(outputDir, htmlRelPath);
717
- const pngAbsPath = join5(outputDir, pngRelPath);
1309
+ const htmlRelPath = join6("renders", routeSlug, `${filePrefix}.html`);
1310
+ const pngRelPath = join6("renders", routeSlug, `${filePrefix}.png`);
1311
+ const htmlAbsPath = join6(outputDir, htmlRelPath);
1312
+ const pngAbsPath = join6(outputDir, pngRelPath);
718
1313
  const page = await context.newPage();
719
1314
  if (viewport) {
720
1315
  await page.setViewportSize({ width: viewport.width, height: viewport.height });
@@ -728,42 +1323,47 @@ async function renderPage(context, devServerUrl, task, outputDir, options, viewp
728
1323
  timeout: options.pageTimeout
729
1324
  });
730
1325
  await page.waitForTimeout(options.settleTime);
731
- await page.evaluate(`(() => {
732
- const links = document.querySelectorAll('link[rel="stylesheet"]');
733
- links.forEach(link => {
734
- try {
735
- const href = link.getAttribute('href');
736
- if (!href) return;
737
- for (const sheet of document.styleSheets) {
738
- if (sheet.href && sheet.href.includes(href.replace(/^\\//, ''))) {
739
- const rules = Array.from(sheet.cssRules).map(r => r.cssText).join('\\n');
740
- const style = document.createElement('style');
741
- style.textContent = rules;
742
- link.parentNode.replaceChild(style, link);
743
- break;
744
- }
745
- }
746
- } catch (e) {}
747
- });
748
- document.querySelectorAll('script').forEach(s => s.remove());
749
- })()`);
1326
+ await inlineStylesAndCleanup(page);
750
1327
  const html = await page.content();
751
- await writeFile(htmlAbsPath, html, "utf-8");
1328
+ await writeFile2(htmlAbsPath, html, "utf-8");
752
1329
  await page.screenshot({ path: pngAbsPath, fullPage: true });
1330
+ const interactionStates = ["success", "empty"];
1331
+ const shouldCapture = options.captureInteractionStates !== false && interactionStates.includes(mockConfig.stateName);
1332
+ let interactions;
1333
+ if (shouldCapture) {
1334
+ await page.goto(fullUrl, { waitUntil: "networkidle", timeout: options.pageTimeout });
1335
+ await page.waitForTimeout(options.settleTime);
1336
+ const interactionResults = await captureInteractions(
1337
+ page,
1338
+ fullUrl,
1339
+ route,
1340
+ mockConfig.stateName,
1341
+ outputDir,
1342
+ { maxInteractions: options.maxInteractions, settleTime: 500 }
1343
+ );
1344
+ const successful = interactionResults.filter((r) => r.success);
1345
+ if (successful.length > 0) {
1346
+ interactions = successful.map((r) => ({
1347
+ description: r.elementDescription,
1348
+ htmlPath: r.htmlPath
1349
+ }));
1350
+ }
1351
+ }
753
1352
  return {
754
1353
  route,
755
1354
  stateName: mockConfig.stateName,
756
1355
  htmlPath: htmlRelPath,
757
1356
  screenshotPath: pngRelPath,
758
1357
  success: true,
759
- viewportName: viewport?.name
1358
+ viewportName: viewport?.name,
1359
+ interactions
760
1360
  };
761
1361
  } catch (err) {
762
1362
  const errorMsg = err instanceof Error ? err.message : String(err);
763
1363
  const errorHtml = `<!DOCTYPE html><html><body style="display:flex;align-items:center;justify-content:center;height:100vh;font-family:sans-serif;color:#666;">
764
1364
  <div style="text-align:center"><h2>Render Failed</h2><p>${route.urlPath} [${mockConfig.stateName}]</p><pre style="color:#c00">${errorMsg}</pre></div>
765
1365
  </body></html>`;
766
- await writeFile(htmlAbsPath, errorHtml, "utf-8").catch(() => {
1366
+ await writeFile2(htmlAbsPath, errorHtml, "utf-8").catch(() => {
767
1367
  });
768
1368
  return {
769
1369
  route,
@@ -820,7 +1420,8 @@ function buildManifest(results, projectName, viewports) {
820
1420
  status: result.success ? "ok" : "error",
821
1421
  error: result.error,
822
1422
  viewport: result.viewportName,
823
- viewportWidth: result.viewportName ? viewportWidthMap.get(result.viewportName) : void 0
1423
+ viewportWidth: result.viewportName ? viewportWidthMap.get(result.viewportName) : void 0,
1424
+ interactions: result.interactions
824
1425
  });
825
1426
  }
826
1427
  return {
@@ -832,16 +1433,18 @@ function buildManifest(results, projectName, viewports) {
832
1433
  async function preRenderPages(tasks, options) {
833
1434
  const {
834
1435
  projectRoot,
835
- outputDir = join5(projectRoot, ".c2d"),
1436
+ outputDir = join6(projectRoot, ".c2d"),
836
1437
  concurrency = DEFAULT_CONCURRENCY,
837
1438
  pageTimeout = DEFAULT_PAGE_TIMEOUT,
838
1439
  settleTime = DEFAULT_SETTLE_TIME,
839
1440
  viewportWidth = DEFAULT_VIEWPORT.width,
840
- viewportHeight = DEFAULT_VIEWPORT.height
1441
+ viewportHeight = DEFAULT_VIEWPORT.height,
1442
+ captureInteractions: captureInteractionStates = true,
1443
+ maxInteractions
841
1444
  } = options;
842
1445
  const viewports = options.viewports;
843
1446
  const useMultiViewport = viewports && viewports.length > 0;
844
- await mkdir(outputDir, { recursive: true });
1447
+ await mkdir2(outputDir, { recursive: true });
845
1448
  let devServer = null;
846
1449
  try {
847
1450
  devServer = await startDevServer(projectRoot, {
@@ -864,20 +1467,20 @@ async function preRenderPages(tasks, options) {
864
1467
  results = await processWithConcurrency(
865
1468
  expandedItems,
866
1469
  concurrency,
867
- ({ task, viewport: vp }) => renderPage(context, devServer.url, task, outputDir, { pageTimeout, settleTime }, vp),
1470
+ ({ task, viewport: vp }) => renderPage(context, devServer.url, task, outputDir, { pageTimeout, settleTime, captureInteractionStates, maxInteractions }, vp),
868
1471
  options.onProgress
869
1472
  );
870
1473
  } else {
871
1474
  results = await processWithConcurrency(
872
1475
  tasks,
873
1476
  concurrency,
874
- (task) => renderPage(context, devServer.url, task, outputDir, { pageTimeout, settleTime }),
1477
+ (task) => renderPage(context, devServer.url, task, outputDir, { pageTimeout, settleTime, captureInteractionStates, maxInteractions }),
875
1478
  options.onProgress
876
1479
  );
877
1480
  }
878
1481
  let projectName = "unknown";
879
1482
  try {
880
- const pkg = await import(join5(projectRoot, "package.json"), { with: { type: "json" } });
1483
+ const pkg = await import(join6(projectRoot, "package.json"), { with: { type: "json" } });
881
1484
  projectName = pkg.default?.name || "unknown";
882
1485
  } catch {
883
1486
  }
@@ -886,8 +1489,8 @@ async function preRenderPages(tasks, options) {
886
1489
  projectName,
887
1490
  useMultiViewport ? viewports : void 0
888
1491
  );
889
- await writeFile(
890
- join5(outputDir, "manifest.json"),
1492
+ await writeFile2(
1493
+ join6(outputDir, "manifest.json"),
891
1494
  JSON.stringify(manifest, null, 2),
892
1495
  "utf-8"
893
1496
  );
@@ -905,14 +1508,14 @@ async function preRenderPages(tasks, options) {
905
1508
 
906
1509
  // src/server/canvas-server.ts
907
1510
  import { createServer as createServer2 } from "http";
908
- import { join as join7 } from "path";
1511
+ import { join as join8 } from "path";
909
1512
  import { existsSync as existsSync5 } from "fs";
910
1513
  import sirv from "sirv";
911
1514
 
912
1515
  // src/server/api-routes.ts
913
- import { readFile as readFile3, writeFile as writeFile2, mkdir as mkdir2 } from "fs/promises";
1516
+ import { readFile as readFile4, writeFile as writeFile3, mkdir as mkdir3 } from "fs/promises";
914
1517
  import { existsSync as existsSync4 } from "fs";
915
- import { join as join6, dirname as dirname2 } from "path";
1518
+ import { join as join7, dirname as dirname2 } from "path";
916
1519
  import { randomUUID } from "crypto";
917
1520
  async function handleApiRequest(req, res, c2dDir) {
918
1521
  const url = req.url ?? "/";
@@ -945,12 +1548,12 @@ async function handleApiRequest(req, res, c2dDir) {
945
1548
  return false;
946
1549
  }
947
1550
  async function handleGetManifest(res, c2dDir) {
948
- const manifestPath = join6(c2dDir, "manifest.json");
1551
+ const manifestPath = join7(c2dDir, "manifest.json");
949
1552
  if (!existsSync4(manifestPath)) {
950
1553
  sendJson(res, 404, { error: "manifest.json not found" });
951
1554
  return;
952
1555
  }
953
- const data = await readFile3(manifestPath, "utf-8");
1556
+ const data = await readFile4(manifestPath, "utf-8");
954
1557
  sendRawJson(res, 200, data);
955
1558
  }
956
1559
  async function handleGetComments(res, c2dDir) {
@@ -1015,11 +1618,11 @@ async function handlePostDrawings(req, res, c2dDir) {
1015
1618
  sendJson(res, 200, { ok: true });
1016
1619
  }
1017
1620
  async function loadDrawings(c2dDir) {
1018
- const drawingsPath = join6(c2dDir, "drawings.json");
1621
+ const drawingsPath = join7(c2dDir, "drawings.json");
1019
1622
  if (!existsSync4(drawingsPath)) {
1020
1623
  return [];
1021
1624
  }
1022
- const data = await readFile3(drawingsPath, "utf-8");
1625
+ const data = await readFile4(drawingsPath, "utf-8");
1023
1626
  try {
1024
1627
  const parsed = JSON.parse(data);
1025
1628
  return Array.isArray(parsed) ? parsed : [];
@@ -1028,12 +1631,12 @@ async function loadDrawings(c2dDir) {
1028
1631
  }
1029
1632
  }
1030
1633
  async function saveDrawings(c2dDir, drawings) {
1031
- const drawingsPath = join6(c2dDir, "drawings.json");
1634
+ const drawingsPath = join7(c2dDir, "drawings.json");
1032
1635
  const dir = dirname2(drawingsPath);
1033
1636
  if (!existsSync4(dir)) {
1034
- await mkdir2(dir, { recursive: true });
1637
+ await mkdir3(dir, { recursive: true });
1035
1638
  }
1036
- await writeFile2(drawingsPath, JSON.stringify(drawings, null, 2), "utf-8");
1639
+ await writeFile3(drawingsPath, JSON.stringify(drawings, null, 2), "utf-8");
1037
1640
  }
1038
1641
  function isValidCommentInput(value) {
1039
1642
  if (typeof value !== "object" || value === null) return false;
@@ -1041,11 +1644,11 @@ function isValidCommentInput(value) {
1041
1644
  return typeof obj.x === "number" && typeof obj.y === "number" && typeof obj.text === "string" && typeof obj.author === "string";
1042
1645
  }
1043
1646
  async function loadComments(c2dDir) {
1044
- const commentsPath = join6(c2dDir, "comments.json");
1647
+ const commentsPath = join7(c2dDir, "comments.json");
1045
1648
  if (!existsSync4(commentsPath)) {
1046
1649
  return [];
1047
1650
  }
1048
- const data = await readFile3(commentsPath, "utf-8");
1651
+ const data = await readFile4(commentsPath, "utf-8");
1049
1652
  try {
1050
1653
  const parsed = JSON.parse(data);
1051
1654
  return Array.isArray(parsed) ? parsed : [];
@@ -1054,12 +1657,12 @@ async function loadComments(c2dDir) {
1054
1657
  }
1055
1658
  }
1056
1659
  async function saveComments(c2dDir, comments) {
1057
- const commentsPath = join6(c2dDir, "comments.json");
1660
+ const commentsPath = join7(c2dDir, "comments.json");
1058
1661
  const dir = dirname2(commentsPath);
1059
1662
  if (!existsSync4(dir)) {
1060
- await mkdir2(dir, { recursive: true });
1663
+ await mkdir3(dir, { recursive: true });
1061
1664
  }
1062
- await writeFile2(commentsPath, JSON.stringify(comments, null, 2), "utf-8");
1665
+ await writeFile3(commentsPath, JSON.stringify(comments, null, 2), "utf-8");
1063
1666
  }
1064
1667
  function readRequestBody(req) {
1065
1668
  return new Promise((resolve2, reject) => {
@@ -1109,9 +1712,9 @@ function tryListen(requestHandler, port) {
1109
1712
  async function startCanvasServer(options) {
1110
1713
  const { port = 4800, canvasDir, c2dDir, projectRoot } = options;
1111
1714
  const canvasHandler = sirv(canvasDir, { single: true, dev: true });
1112
- const rendersDir = join7(c2dDir, "renders");
1715
+ const rendersDir = join8(c2dDir, "renders");
1113
1716
  const rendersHandler = sirv(rendersDir, { dev: true });
1114
- const publicDir = projectRoot ? join7(projectRoot, "public") : null;
1717
+ const publicDir = projectRoot ? join8(projectRoot, "public") : null;
1115
1718
  const publicHandler = publicDir && existsSync5(publicDir) ? sirv(publicDir, { dev: true }) : null;
1116
1719
  const requestHandler = async (req, res) => {
1117
1720
  const url = req.url ?? "/";
@@ -1176,12 +1779,12 @@ async function startCanvasServer(options) {
1176
1779
 
1177
1780
  // src/config.ts
1178
1781
  import { existsSync as existsSync6 } from "fs";
1179
- import { readFile as readFile4 } from "fs/promises";
1180
- import { join as join8 } from "path";
1782
+ import { readFile as readFile5 } from "fs/promises";
1783
+ import { join as join9 } from "path";
1181
1784
  var DEFAULT_PORT = 4800;
1182
1785
  async function loadConfig(projectRoot) {
1183
1786
  let fileConfig = {};
1184
- const configPath = join8(projectRoot, "c2d.config.js");
1787
+ const configPath = join9(projectRoot, "c2d.config.js");
1185
1788
  if (existsSync6(configPath)) {
1186
1789
  try {
1187
1790
  const mod = await import(configPath);
@@ -1198,24 +1801,51 @@ async function loadConfig(projectRoot) {
1198
1801
  devServerCommand: fileConfig.devServerCommand
1199
1802
  };
1200
1803
  }
1201
- async function detectNextJsProject(projectRoot) {
1202
- const hasNextConfig = existsSync6(join8(projectRoot, "next.config.ts")) || existsSync6(join8(projectRoot, "next.config.js")) || existsSync6(join8(projectRoot, "next.config.mjs"));
1203
- let appDir = join8(projectRoot, "app");
1204
- let hasAppDir = existsSync6(appDir);
1205
- if (!hasAppDir) {
1206
- appDir = join8(projectRoot, "src", "app");
1207
- hasAppDir = existsSync6(appDir);
1208
- }
1804
+ async function detectProject(projectRoot) {
1209
1805
  let projectName = "unknown";
1210
1806
  try {
1211
- const pkg = JSON.parse(await readFile4(join8(projectRoot, "package.json"), "utf-8"));
1807
+ const pkg = JSON.parse(await readFile5(join9(projectRoot, "package.json"), "utf-8"));
1212
1808
  projectName = pkg.name || "unknown";
1213
1809
  } catch {
1214
1810
  }
1811
+ const hasNextConfig = existsSync6(join9(projectRoot, "next.config.ts")) || existsSync6(join9(projectRoot, "next.config.js")) || existsSync6(join9(projectRoot, "next.config.mjs"));
1812
+ if (hasNextConfig) {
1813
+ let appDir = join9(projectRoot, "app");
1814
+ let hasAppDir = existsSync6(appDir);
1815
+ if (!hasAppDir) {
1816
+ appDir = join9(projectRoot, "src", "app");
1817
+ hasAppDir = existsSync6(appDir);
1818
+ }
1819
+ if (hasAppDir) {
1820
+ return { isSupported: true, projectType: "nextjs-app", appDir, projectRoot, projectName };
1821
+ }
1822
+ appDir = join9(projectRoot, "pages");
1823
+ hasAppDir = existsSync6(appDir);
1824
+ if (!hasAppDir) {
1825
+ appDir = join9(projectRoot, "src", "pages");
1826
+ hasAppDir = existsSync6(appDir);
1827
+ }
1828
+ if (hasAppDir) {
1829
+ return { isSupported: true, projectType: "nextjs-pages", appDir, projectRoot, projectName };
1830
+ }
1831
+ return { isSupported: false, projectType: "unknown", appDir: null, projectRoot, projectName };
1832
+ }
1833
+ try {
1834
+ const pkg = JSON.parse(await readFile5(join9(projectRoot, "package.json"), "utf-8"));
1835
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
1836
+ if ("react-router-dom" in deps || "react-router" in deps) {
1837
+ return { isSupported: true, projectType: "react-router", appDir: null, projectRoot, projectName };
1838
+ }
1839
+ } catch {
1840
+ }
1841
+ return { isSupported: false, projectType: "unknown", appDir: null, projectRoot, projectName };
1842
+ }
1843
+ async function detectNextJsProject(projectRoot) {
1844
+ const result = await detectProject(projectRoot);
1215
1845
  return {
1216
- isNextJs: hasNextConfig,
1217
- appDir: hasAppDir ? appDir : null,
1218
- projectName
1846
+ isNextJs: result.projectType === "nextjs-app" || result.projectType === "nextjs-pages",
1847
+ appDir: result.appDir,
1848
+ projectName: result.projectName
1219
1849
  };
1220
1850
  }
1221
1851
 
@@ -1262,21 +1892,23 @@ async function runScan(options) {
1262
1892
  banner();
1263
1893
  const config = await loadConfig(projectRoot);
1264
1894
  header("Detecting project...");
1265
- const project = await detectNextJsProject(projectRoot);
1266
- if (!project.isNextJs) {
1267
- error("Not a Next.js project (no next.config.ts/js/mjs found).");
1268
- log("Code to Design currently supports Next.js App Router projects only.");
1895
+ const project = await detectProject(projectRoot);
1896
+ if (!project.isSupported) {
1897
+ error("Unsupported project type.");
1898
+ log("Code to Design supports Next.js (App Router / Pages Router) and React Router (Vite) projects.");
1269
1899
  process.exit(1);
1270
1900
  }
1271
- if (!project.appDir) {
1272
- error("No app/ directory found. Code to Design requires Next.js App Router.");
1901
+ if (project.projectType !== "react-router" && !project.appDir) {
1902
+ error("No app/ or pages/ directory found.");
1273
1903
  process.exit(1);
1274
1904
  }
1275
- success(`Project: ${project.projectName}`);
1276
- success(`App directory: ${project.appDir}`);
1277
- const c2dDir = join9(projectRoot, ".c2d");
1905
+ success(`Project: ${project.projectName} (${project.projectType})`);
1906
+ if (project.appDir) {
1907
+ success(`App directory: ${project.appDir}`);
1908
+ }
1909
+ const c2dDir = join10(projectRoot, ".c2d");
1278
1910
  if (skipRender) {
1279
- if (!existsSync7(join9(c2dDir, "manifest.json"))) {
1911
+ if (!existsSync7(join10(c2dDir, "manifest.json"))) {
1280
1912
  error("No previous renders found. Run without --skip-render first.");
1281
1913
  process.exit(1);
1282
1914
  }
@@ -1285,7 +1917,9 @@ async function runScan(options) {
1285
1917
  return;
1286
1918
  }
1287
1919
  header("Discovering routes...");
1288
- const routes = await scanRoutes({ appDir: project.appDir });
1920
+ const scanDir2 = project.projectType === "react-router" ? project.projectRoot : project.appDir;
1921
+ const routerType = project.projectType === "react-router" ? "react-router" : project.projectType === "nextjs-pages" ? "pages-router" : "app-router";
1922
+ const routes = await scanRoutes({ appDir: scanDir2, routerType });
1289
1923
  if (routes.length === 0) {
1290
1924
  error("No routes found in app/ directory.");
1291
1925
  process.exit(1);
@@ -1328,10 +1962,10 @@ async function runScan(options) {
1328
1962
  success("Using fallback mocks (no API key or no API dependencies)");
1329
1963
  }
1330
1964
  header(`Pre-rendering ${renderTasks.length} page states...`);
1331
- if (existsSync7(join9(c2dDir, "renders"))) {
1332
- await rm(join9(c2dDir, "renders"), { recursive: true });
1965
+ if (existsSync7(join10(c2dDir, "renders"))) {
1966
+ await rm(join10(c2dDir, "renders"), { recursive: true });
1333
1967
  }
1334
- await mkdir3(c2dDir, { recursive: true });
1968
+ await mkdir4(c2dDir, { recursive: true });
1335
1969
  const { results, manifest } = await preRenderPages(renderTasks, {
1336
1970
  projectRoot,
1337
1971
  outputDir: c2dDir,
@@ -1355,7 +1989,8 @@ async function runScan(options) {
1355
1989
  }
1356
1990
  if (watch) {
1357
1991
  const server = await startServerNonBlocking(c2dDir, config.port, open, projectRoot);
1358
- watchAndRerender(projectRoot, project.appDir, c2dDir, config);
1992
+ const watchDir = project.projectType === "react-router" ? join10(projectRoot, "src") : project.appDir;
1993
+ watchAndRerender(projectRoot, watchDir, c2dDir, config, routerType);
1359
1994
  await new Promise((resolve2) => {
1360
1995
  const shutdown = async () => {
1361
1996
  log("\nShutting down...");
@@ -1431,20 +2066,20 @@ async function resolveCanvasDir(c2dDir) {
1431
2066
  const __filename = fileURLToPath(import.meta.url);
1432
2067
  let dir = dirname3(__filename);
1433
2068
  for (let i = 0; i < 5; i++) {
1434
- const candidate = join9(dir, "canvas-dist");
1435
- if (existsSync7(candidate) && existsSync7(join9(candidate, "index.html"))) {
2069
+ const candidate = join10(dir, "canvas-dist");
2070
+ if (existsSync7(candidate) && existsSync7(join10(candidate, "index.html"))) {
1436
2071
  return candidate;
1437
2072
  }
1438
2073
  dir = dirname3(dir);
1439
2074
  }
1440
- const monorepoDev = join9(dirname3(__filename), "..", "..", "..", "..", "apps", "canvas", "dist");
1441
- if (existsSync7(monorepoDev) && existsSync7(join9(monorepoDev, "index.html"))) {
2075
+ const monorepoDev = join10(dirname3(__filename), "..", "..", "..", "..", "apps", "canvas", "dist");
2076
+ if (existsSync7(monorepoDev) && existsSync7(join10(monorepoDev, "index.html"))) {
1442
2077
  return monorepoDev;
1443
2078
  }
1444
- const placeholder = join9(c2dDir, "_canvas");
1445
- await mkdir3(placeholder, { recursive: true });
1446
- const { writeFile: writeFile3 } = await import("fs/promises");
1447
- await writeFile3(join9(placeholder, "index.html"), `<!DOCTYPE html><html><body>
2079
+ const placeholder = join10(c2dDir, "_canvas");
2080
+ await mkdir4(placeholder, { recursive: true });
2081
+ const { writeFile: writeFile4 } = await import("fs/promises");
2082
+ await writeFile4(join10(placeholder, "index.html"), `<!DOCTYPE html><html><body>
1448
2083
  <h1>Code to Design</h1>
1449
2084
  <p>Canvas app not built. Run: <code>cd apps/canvas && npx vite build</code></p>
1450
2085
  <p><a href="/api/manifest">View Manifest</a></p>
@@ -1459,7 +2094,7 @@ function shouldIgnoreFile(filename) {
1459
2094
  const ext = filename.slice(filename.lastIndexOf("."));
1460
2095
  return !WATCH_EXTENSIONS.has(ext);
1461
2096
  }
1462
- function watchAndRerender(projectRoot, appDir, c2dDir, config) {
2097
+ function watchAndRerender(projectRoot, appDir, c2dDir, config, routerType) {
1463
2098
  let debounceTimer;
1464
2099
  let isRendering = false;
1465
2100
  log(`Watching ${appDir} for changes...`);
@@ -1472,7 +2107,7 @@ function watchAndRerender(projectRoot, appDir, c2dDir, config) {
1472
2107
  log(`
1473
2108
  File changed: ${filename}. Re-rendering...`);
1474
2109
  try {
1475
- const routes = await scanRoutes({ appDir });
2110
+ const routes = await scanRoutes({ appDir, routerType });
1476
2111
  const filteredRoutes = routes.filter(
1477
2112
  (r) => !config.excludeRoutes.some((pattern) => r.urlPath.includes(pattern))
1478
2113
  );
@@ -1489,10 +2124,10 @@ File changed: ${filename}. Re-rendering...`);
1489
2124
  });
1490
2125
  }
1491
2126
  }
1492
- if (existsSync7(join9(c2dDir, "renders"))) {
1493
- await rm(join9(c2dDir, "renders"), { recursive: true });
2127
+ if (existsSync7(join10(c2dDir, "renders"))) {
2128
+ await rm(join10(c2dDir, "renders"), { recursive: true });
1494
2129
  }
1495
- await mkdir3(c2dDir, { recursive: true });
2130
+ await mkdir4(c2dDir, { recursive: true });
1496
2131
  const { results } = await preRenderPages(renderTasks, {
1497
2132
  projectRoot,
1498
2133
  outputDir: c2dDir,
@@ -1512,7 +2147,8 @@ File changed: ${filename}. Re-rendering...`);
1512
2147
  export {
1513
2148
  startCanvasServer,
1514
2149
  loadConfig,
2150
+ detectProject,
1515
2151
  detectNextJsProject,
1516
2152
  runScan
1517
2153
  };
1518
- //# sourceMappingURL=chunk-WX4KLWOS.js.map
2154
+ //# sourceMappingURL=chunk-AL6L2SWU.js.map