code-to-design 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/code-to-design.js +42 -0
- package/canvas-dist/assets/index-C2Bl36s_.js +40 -0
- package/canvas-dist/index.html +12 -0
- package/dist/chunk-QJKUBMF6.js +1471 -0
- package/dist/chunk-QJKUBMF6.js.map +1 -0
- package/dist/commands/scan.d.ts +17 -0
- package/dist/commands/scan.js +7 -0
- package/dist/commands/scan.js.map +1 -0
- package/dist/index.d.ts +60 -0
- package/dist/index.js +17 -0
- package/dist/index.js.map +1 -0
- package/package.json +34 -0
|
@@ -0,0 +1,1471 @@
|
|
|
1
|
+
// src/commands/scan.ts
|
|
2
|
+
import { join as join9 } from "path";
|
|
3
|
+
import { rm, mkdir as mkdir3 } from "fs/promises";
|
|
4
|
+
import { existsSync as existsSync6, watch as fsWatch } from "fs";
|
|
5
|
+
|
|
6
|
+
// ../core/src/discovery/route-scanner.ts
|
|
7
|
+
import { readdir, stat } from "fs/promises";
|
|
8
|
+
import { join, extname } from "path";
|
|
9
|
+
var PAGE_EXTENSIONS = /* @__PURE__ */ new Set([".js", ".jsx", ".ts", ".tsx"]);
|
|
10
|
+
var PAGE_BASENAMES = /* @__PURE__ */ new Set(["page"]);
|
|
11
|
+
function isPageFile(filename) {
|
|
12
|
+
const ext = extname(filename);
|
|
13
|
+
const basename = filename.slice(0, -ext.length);
|
|
14
|
+
return PAGE_EXTENSIONS.has(ext) && PAGE_BASENAMES.has(basename);
|
|
15
|
+
}
|
|
16
|
+
function shouldSkipDir(name) {
|
|
17
|
+
if (name.startsWith("_")) return true;
|
|
18
|
+
if (name.startsWith("@")) return true;
|
|
19
|
+
if (name.startsWith("(.)") || name.startsWith("(..)")) return true;
|
|
20
|
+
if (name === "node_modules" || name === ".next") return true;
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
function isRouteGroup(name) {
|
|
24
|
+
return name.startsWith("(") && name.endsWith(")") && !name.startsWith("(.");
|
|
25
|
+
}
|
|
26
|
+
function parseDynamicSegment(segment) {
|
|
27
|
+
const optionalCatchAll = segment.match(/^\[\[\.\.\.(.+)\]\]$/);
|
|
28
|
+
if (optionalCatchAll) {
|
|
29
|
+
return { name: optionalCatchAll[1], isCatchAll: true, isOptional: true };
|
|
30
|
+
}
|
|
31
|
+
const catchAll = segment.match(/^\[\.\.\.(.+)\]$/);
|
|
32
|
+
if (catchAll) {
|
|
33
|
+
return { name: catchAll[1], isCatchAll: true, isOptional: false };
|
|
34
|
+
}
|
|
35
|
+
const dynamic = segment.match(/^\[(.+)\]$/);
|
|
36
|
+
if (dynamic) {
|
|
37
|
+
return { name: dynamic[1], isCatchAll: false, isOptional: false };
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
function segmentToUrlPart(segment) {
|
|
42
|
+
const param = parseDynamicSegment(segment);
|
|
43
|
+
if (!param) return segment;
|
|
44
|
+
if (param.isCatchAll && param.isOptional) return `:${param.name}*`;
|
|
45
|
+
if (param.isCatchAll) return `:${param.name}+`;
|
|
46
|
+
return `:${param.name}`;
|
|
47
|
+
}
|
|
48
|
+
async function scanDir(dirPath, urlSegments, params) {
|
|
49
|
+
const routes = [];
|
|
50
|
+
let entries;
|
|
51
|
+
try {
|
|
52
|
+
entries = await readdir(dirPath);
|
|
53
|
+
} catch {
|
|
54
|
+
return routes;
|
|
55
|
+
}
|
|
56
|
+
for (const entry of entries) {
|
|
57
|
+
const entryPath = join(dirPath, entry);
|
|
58
|
+
const entryStat = await stat(entryPath).catch(() => null);
|
|
59
|
+
if (!entryStat) continue;
|
|
60
|
+
if (entryStat.isFile() && isPageFile(entry)) {
|
|
61
|
+
const urlPath = "/" + urlSegments.join("/");
|
|
62
|
+
routes.push({
|
|
63
|
+
urlPath: urlPath || "/",
|
|
64
|
+
filePath: entryPath,
|
|
65
|
+
params: [...params],
|
|
66
|
+
isDynamic: params.length > 0
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
if (entryStat.isDirectory()) {
|
|
70
|
+
if (shouldSkipDir(entry)) continue;
|
|
71
|
+
if (isRouteGroup(entry)) {
|
|
72
|
+
const nested = await scanDir(entryPath, urlSegments, params);
|
|
73
|
+
routes.push(...nested);
|
|
74
|
+
} else {
|
|
75
|
+
const param = parseDynamicSegment(entry);
|
|
76
|
+
const urlPart = segmentToUrlPart(entry);
|
|
77
|
+
const newParams = param ? [...params, param] : params;
|
|
78
|
+
const nested = await scanDir(entryPath, [...urlSegments, urlPart], newParams);
|
|
79
|
+
routes.push(...nested);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return routes;
|
|
84
|
+
}
|
|
85
|
+
async function scanRoutes(options) {
|
|
86
|
+
const { appDir } = options;
|
|
87
|
+
const dirStat = await stat(appDir).catch(() => null);
|
|
88
|
+
if (!dirStat || !dirStat.isDirectory()) {
|
|
89
|
+
throw new Error(`App directory not found: ${appDir}`);
|
|
90
|
+
}
|
|
91
|
+
const routes = await scanDir(appDir, [], []);
|
|
92
|
+
routes.sort((a, b) => a.urlPath.localeCompare(b.urlPath));
|
|
93
|
+
return routes;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ../core/src/analysis/code-analyzer.ts
|
|
97
|
+
import { readFile as readFile2 } from "fs/promises";
|
|
98
|
+
import { join as join3, dirname, resolve } from "path";
|
|
99
|
+
import { existsSync as existsSync2 } from "fs";
|
|
100
|
+
|
|
101
|
+
// ../core/src/analysis/auth-detector.ts
|
|
102
|
+
import { readFile } from "fs/promises";
|
|
103
|
+
import { join as join2 } from "path";
|
|
104
|
+
import { existsSync } from "fs";
|
|
105
|
+
var MIDDLEWARE_FILES = ["middleware.ts", "middleware.js", "middleware.tsx", "middleware.jsx"];
|
|
106
|
+
function extractCookieNames(source) {
|
|
107
|
+
const cookies = [];
|
|
108
|
+
const regex = /cookies\.get\(\s*['"]([^'"]+)['"]\s*\)/g;
|
|
109
|
+
let match;
|
|
110
|
+
while ((match = regex.exec(source)) !== null) {
|
|
111
|
+
cookies.push(match[1]);
|
|
112
|
+
}
|
|
113
|
+
return [...new Set(cookies)];
|
|
114
|
+
}
|
|
115
|
+
function extractApiBaseUrl(source) {
|
|
116
|
+
const regex = /process\.env\.(\w*API_URL\w*)\s*\|\|\s*['"]([^'"]+)['"]/;
|
|
117
|
+
const match = source.match(regex);
|
|
118
|
+
if (match) return match[2];
|
|
119
|
+
const simpleRegex = /['"]https?:\/\/[^'"]+\/api['"]/;
|
|
120
|
+
const simpleMatch = source.match(simpleRegex);
|
|
121
|
+
if (simpleMatch) return simpleMatch[0].replace(/['"]/g, "");
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
function extractAuthCheckEndpoint(source) {
|
|
125
|
+
const patterns = [
|
|
126
|
+
/[`'"](\/auth\/me)[`'"]/,
|
|
127
|
+
/[`'"](\/api\/auth\/me)[`'"]/,
|
|
128
|
+
/[`'"](\/auth\/session)[`'"]/,
|
|
129
|
+
/[`'"](\/api\/auth\/session)[`'"]/,
|
|
130
|
+
/[`'"](\/auth\/user)[`'"]/,
|
|
131
|
+
/[`'"](\/api\/user\/me)[`'"]/
|
|
132
|
+
];
|
|
133
|
+
for (const pattern of patterns) {
|
|
134
|
+
const match = source.match(pattern);
|
|
135
|
+
if (match) return match[1];
|
|
136
|
+
}
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
async function detectAuth(projectRoot, allSources) {
|
|
140
|
+
const config = {
|
|
141
|
+
hasAuth: false,
|
|
142
|
+
cookieNames: [],
|
|
143
|
+
authCheckEndpoint: null,
|
|
144
|
+
apiBaseUrl: null
|
|
145
|
+
};
|
|
146
|
+
for (const filename of MIDDLEWARE_FILES) {
|
|
147
|
+
const middlewarePath = join2(projectRoot, filename);
|
|
148
|
+
if (existsSync(middlewarePath)) {
|
|
149
|
+
try {
|
|
150
|
+
const source = await readFile(middlewarePath, "utf-8");
|
|
151
|
+
const cookies = extractCookieNames(source);
|
|
152
|
+
if (cookies.length > 0) {
|
|
153
|
+
config.hasAuth = true;
|
|
154
|
+
config.cookieNames.push(...cookies);
|
|
155
|
+
}
|
|
156
|
+
} catch {
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
const combinedSource = allSources.join("\n");
|
|
161
|
+
const authEndpoint = extractAuthCheckEndpoint(combinedSource);
|
|
162
|
+
if (authEndpoint) {
|
|
163
|
+
config.hasAuth = true;
|
|
164
|
+
config.authCheckEndpoint = authEndpoint;
|
|
165
|
+
}
|
|
166
|
+
if (/useAuth|AuthProvider|AuthContext|SessionProvider/i.test(combinedSource)) {
|
|
167
|
+
config.hasAuth = true;
|
|
168
|
+
}
|
|
169
|
+
config.apiBaseUrl = extractApiBaseUrl(combinedSource);
|
|
170
|
+
return config;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ../core/src/analysis/code-analyzer.ts
|
|
174
|
+
var DEFAULT_MAX_TOKENS = 8e3;
|
|
175
|
+
var CHARS_PER_TOKEN = 4;
|
|
176
|
+
function extractImportPaths(source) {
|
|
177
|
+
const paths = [];
|
|
178
|
+
const regex = /import\s+(?:[\s\S]*?\s+from\s+)?['"]([^'"]+)['"]/g;
|
|
179
|
+
let match;
|
|
180
|
+
while ((match = regex.exec(source)) !== null) {
|
|
181
|
+
paths.push(match[1]);
|
|
182
|
+
}
|
|
183
|
+
return paths;
|
|
184
|
+
}
|
|
185
|
+
function resolvePathAlias(importPath, aliases) {
|
|
186
|
+
for (const [pattern, replacement] of Object.entries(aliases)) {
|
|
187
|
+
const prefix = pattern.replace(/\*$/, "");
|
|
188
|
+
if (importPath.startsWith(prefix)) {
|
|
189
|
+
const rest = importPath.slice(prefix.length);
|
|
190
|
+
return replacement.replace(/\*$/, "") + rest;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
async function readPathAliases(projectRoot) {
|
|
196
|
+
const aliases = {};
|
|
197
|
+
for (const filename of ["tsconfig.json", "jsconfig.json"]) {
|
|
198
|
+
const configPath = join3(projectRoot, filename);
|
|
199
|
+
if (!existsSync2(configPath)) continue;
|
|
200
|
+
try {
|
|
201
|
+
const content = await readFile2(configPath, "utf-8");
|
|
202
|
+
const stripped = content.replace(
|
|
203
|
+
/"(?:[^"\\]|\\.)*"|\/\/.*$|\/\*[\s\S]*?\*\//gm,
|
|
204
|
+
(match) => match.startsWith('"') ? match : ""
|
|
205
|
+
);
|
|
206
|
+
const config = JSON.parse(stripped);
|
|
207
|
+
const paths = config.compilerOptions?.paths;
|
|
208
|
+
const baseUrl = config.compilerOptions?.baseUrl || ".";
|
|
209
|
+
const resolvedBaseUrl = resolve(projectRoot, baseUrl);
|
|
210
|
+
if (paths) {
|
|
211
|
+
for (const [key, values] of Object.entries(paths)) {
|
|
212
|
+
const targets = values;
|
|
213
|
+
if (targets.length > 0) {
|
|
214
|
+
aliases[key] = join3(resolvedBaseUrl, targets[0]);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
} catch {
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return aliases;
|
|
222
|
+
}
|
|
223
|
+
function resolveImportToFile(importPath, fromDir) {
|
|
224
|
+
const extensions = [".ts", ".tsx", ".js", ".jsx"];
|
|
225
|
+
const candidates = [];
|
|
226
|
+
const resolved = resolve(fromDir, importPath);
|
|
227
|
+
candidates.push(resolved);
|
|
228
|
+
for (const ext of extensions) {
|
|
229
|
+
candidates.push(resolved + ext);
|
|
230
|
+
}
|
|
231
|
+
for (const ext of extensions) {
|
|
232
|
+
candidates.push(join3(resolved, `index${ext}`));
|
|
233
|
+
}
|
|
234
|
+
for (const candidate of candidates) {
|
|
235
|
+
if (existsSync2(candidate)) return candidate;
|
|
236
|
+
}
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
async function traceImports(filePath, projectRoot, aliases, maxDepth, visited = /* @__PURE__ */ new Set()) {
|
|
240
|
+
const results = /* @__PURE__ */ new Map();
|
|
241
|
+
if (visited.has(filePath) || maxDepth < 0) return results;
|
|
242
|
+
visited.add(filePath);
|
|
243
|
+
let source;
|
|
244
|
+
try {
|
|
245
|
+
source = await readFile2(filePath, "utf-8");
|
|
246
|
+
} catch {
|
|
247
|
+
return results;
|
|
248
|
+
}
|
|
249
|
+
results.set(filePath, source);
|
|
250
|
+
const importPaths = extractImportPaths(source);
|
|
251
|
+
const fileDir = dirname(filePath);
|
|
252
|
+
for (const importPath of importPaths) {
|
|
253
|
+
if (!importPath.startsWith(".") && !importPath.startsWith("@/") && !importPath.startsWith("~/")) {
|
|
254
|
+
const aliasResolved = resolvePathAlias(importPath, aliases);
|
|
255
|
+
if (!aliasResolved) continue;
|
|
256
|
+
const resolved = resolveImportToFile(aliasResolved, projectRoot);
|
|
257
|
+
if (resolved && !visited.has(resolved)) {
|
|
258
|
+
const nested = await traceImports(resolved, projectRoot, aliases, maxDepth - 1, visited);
|
|
259
|
+
for (const [path, content] of nested) {
|
|
260
|
+
results.set(path, content);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
} else if (importPath.startsWith(".")) {
|
|
264
|
+
const resolved = resolveImportToFile(importPath, fileDir);
|
|
265
|
+
if (resolved && !visited.has(resolved)) {
|
|
266
|
+
const nested = await traceImports(resolved, projectRoot, aliases, maxDepth - 1, visited);
|
|
267
|
+
for (const [path, content] of nested) {
|
|
268
|
+
results.set(path, content);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
} else {
|
|
272
|
+
const aliasResolved = resolvePathAlias(importPath, aliases);
|
|
273
|
+
if (aliasResolved) {
|
|
274
|
+
const resolved = resolveImportToFile(aliasResolved, projectRoot);
|
|
275
|
+
if (resolved && !visited.has(resolved)) {
|
|
276
|
+
const nested = await traceImports(resolved, projectRoot, aliases, maxDepth - 1, visited);
|
|
277
|
+
for (const [path, content] of nested) {
|
|
278
|
+
results.set(path, content);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return results;
|
|
285
|
+
}
|
|
286
|
+
function estimateTokens(text) {
|
|
287
|
+
return Math.ceil(text.length / CHARS_PER_TOKEN);
|
|
288
|
+
}
|
|
289
|
+
function hasApiCalls(source) {
|
|
290
|
+
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
|
+
}
|
|
292
|
+
async function analyzePage(route, options) {
|
|
293
|
+
const { projectRoot, maxTokensPerPage = DEFAULT_MAX_TOKENS } = options;
|
|
294
|
+
const maxChars = maxTokensPerPage * CHARS_PER_TOKEN;
|
|
295
|
+
const aliases = await readPathAliases(projectRoot);
|
|
296
|
+
const tracedFiles = await traceImports(route.filePath, projectRoot, aliases, 2);
|
|
297
|
+
const resolvedImports = [...tracedFiles.keys()].filter((p) => p !== route.filePath);
|
|
298
|
+
const allSources = [...tracedFiles.values()];
|
|
299
|
+
let sourceContext = "";
|
|
300
|
+
for (const [filePath, content] of tracedFiles) {
|
|
301
|
+
const relativePath = filePath.startsWith(projectRoot) ? filePath.slice(projectRoot.length + 1) : filePath;
|
|
302
|
+
const section = `
|
|
303
|
+
// === ${relativePath} ===
|
|
304
|
+
${content}
|
|
305
|
+
`;
|
|
306
|
+
if (sourceContext.length + section.length > maxChars) {
|
|
307
|
+
break;
|
|
308
|
+
}
|
|
309
|
+
sourceContext += section;
|
|
310
|
+
}
|
|
311
|
+
const authConfig = await detectAuth(projectRoot, allSources);
|
|
312
|
+
const combinedSource = allSources.join("\n");
|
|
313
|
+
const hasApi = hasApiCalls(sourceContext) || hasApiCalls(combinedSource);
|
|
314
|
+
const allImportPaths = allSources.flatMap(extractImportPaths);
|
|
315
|
+
const resolvedSet = new Set(resolvedImports.map((p) => p));
|
|
316
|
+
const unresolvedImports = allImportPaths.filter((p) => p.startsWith(".") || p.startsWith("@/") || p.startsWith("~/")).filter((p) => {
|
|
317
|
+
const aliasResolved = resolvePathAlias(p, aliases);
|
|
318
|
+
const checkPath = aliasResolved ? resolveImportToFile(aliasResolved, projectRoot) : resolveImportToFile(p, dirname(route.filePath));
|
|
319
|
+
return !checkPath;
|
|
320
|
+
});
|
|
321
|
+
return {
|
|
322
|
+
route,
|
|
323
|
+
sourceContext,
|
|
324
|
+
resolvedImports,
|
|
325
|
+
unresolvedImports: [...new Set(unresolvedImports)],
|
|
326
|
+
authConfig,
|
|
327
|
+
hasApiDependencies: hasApi,
|
|
328
|
+
estimatedTokens: estimateTokens(sourceContext)
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// ../core/src/mock/types.ts
|
|
333
|
+
var ALL_STATE_VARIANTS = ["success", "empty", "error", "loading"];
|
|
334
|
+
|
|
335
|
+
// ../core/src/mock/llm-client.ts
|
|
336
|
+
import Anthropic from "@anthropic-ai/sdk";
|
|
337
|
+
var LlmClient = class {
|
|
338
|
+
client;
|
|
339
|
+
model;
|
|
340
|
+
constructor(apiKey, model = "claude-sonnet-4-20250514") {
|
|
341
|
+
this.client = new Anthropic({ apiKey });
|
|
342
|
+
this.model = model;
|
|
343
|
+
}
|
|
344
|
+
async generate(systemPrompt, userPrompt) {
|
|
345
|
+
const response = await this.client.messages.create({
|
|
346
|
+
model: this.model,
|
|
347
|
+
max_tokens: 4096,
|
|
348
|
+
system: systemPrompt,
|
|
349
|
+
messages: [{ role: "user", content: userPrompt }]
|
|
350
|
+
});
|
|
351
|
+
const textBlock = response.content.find((block) => block.type === "text");
|
|
352
|
+
const content = textBlock ? textBlock.text : "";
|
|
353
|
+
return {
|
|
354
|
+
content,
|
|
355
|
+
inputTokens: response.usage.input_tokens,
|
|
356
|
+
outputTokens: response.usage.output_tokens
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
// ../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.
|
|
364
|
+
|
|
365
|
+
Rules:
|
|
366
|
+
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.
|
|
368
|
+
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.`;
|
|
376
|
+
function buildUserPrompt(analysis) {
|
|
377
|
+
const parts = [];
|
|
378
|
+
parts.push(`## Page Route: ${analysis.route.urlPath}`);
|
|
379
|
+
parts.push(`## Page File: ${analysis.route.filePath}`);
|
|
380
|
+
if (analysis.route.isDynamic) {
|
|
381
|
+
parts.push(`## Dynamic Parameters: ${analysis.route.params.map((p) => p.name).join(", ")}`);
|
|
382
|
+
}
|
|
383
|
+
parts.push("## Source Code Context");
|
|
384
|
+
parts.push(analysis.sourceContext);
|
|
385
|
+
if (analysis.authConfig.hasAuth) {
|
|
386
|
+
parts.push("## Auth Configuration");
|
|
387
|
+
parts.push(`- Requires authentication: yes`);
|
|
388
|
+
if (analysis.authConfig.cookieNames.length > 0) {
|
|
389
|
+
parts.push(`- Auth cookies: ${analysis.authConfig.cookieNames.join(", ")}`);
|
|
390
|
+
}
|
|
391
|
+
if (analysis.authConfig.authCheckEndpoint) {
|
|
392
|
+
parts.push(`- Auth check endpoint: ${analysis.authConfig.authCheckEndpoint}`);
|
|
393
|
+
}
|
|
394
|
+
if (analysis.authConfig.apiBaseUrl) {
|
|
395
|
+
parts.push(`- API base URL: ${analysis.authConfig.apiBaseUrl}`);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
parts.push(`
|
|
399
|
+
## Required Output Format
|
|
400
|
+
|
|
401
|
+
Return a JSON object with this exact structure:
|
|
402
|
+
{
|
|
403
|
+
"routeParams": { "paramName": "sampleValue" }, // only if dynamic route
|
|
404
|
+
"apiEndpoints": [
|
|
405
|
+
{
|
|
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 }
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
],
|
|
416
|
+
"authMock": { // only if auth is required
|
|
417
|
+
"cookies": { "cookie_name": "mock_value" },
|
|
418
|
+
"authCheckEndpoint": "/auth/me",
|
|
419
|
+
"authCheckResponse": { "status": 200, "body": { ... } }
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
Generate ONLY the JSON. No explanation, no markdown fences.`);
|
|
424
|
+
return parts.join("\n\n");
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// ../core/src/mock/mock-generator.ts
|
|
428
|
+
function parseLlmJson(content) {
|
|
429
|
+
let cleaned = content.trim();
|
|
430
|
+
if (cleaned.startsWith("```")) {
|
|
431
|
+
cleaned = cleaned.replace(/^```(?:json)?\n?/, "").replace(/\n?```$/, "");
|
|
432
|
+
}
|
|
433
|
+
try {
|
|
434
|
+
return JSON.parse(cleaned);
|
|
435
|
+
} catch {
|
|
436
|
+
const jsonMatch = cleaned.match(/\{[\s\S]*\}/);
|
|
437
|
+
if (jsonMatch) {
|
|
438
|
+
try {
|
|
439
|
+
return JSON.parse(jsonMatch[0]);
|
|
440
|
+
} catch {
|
|
441
|
+
return null;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
return null;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
function convertToMockConfigs(output, analysis, variants) {
|
|
448
|
+
const configs = [];
|
|
449
|
+
for (const variant of variants) {
|
|
450
|
+
const apiMocks = {};
|
|
451
|
+
if (output.apiEndpoints) {
|
|
452
|
+
for (const endpoint of output.apiEndpoints) {
|
|
453
|
+
const stateData = endpoint.states[variant];
|
|
454
|
+
if (stateData) {
|
|
455
|
+
const key = `${endpoint.method} ${endpoint.urlPattern}`;
|
|
456
|
+
apiMocks[key] = {
|
|
457
|
+
status: stateData.status,
|
|
458
|
+
body: stateData.body,
|
|
459
|
+
delay: stateData.delay
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
let authMock = null;
|
|
465
|
+
if (analysis.authConfig.hasAuth && output.authMock) {
|
|
466
|
+
authMock = {
|
|
467
|
+
cookies: output.authMock.cookies,
|
|
468
|
+
authCheckResponse: {
|
|
469
|
+
status: output.authMock.authCheckResponse.status,
|
|
470
|
+
body: output.authMock.authCheckResponse.body
|
|
471
|
+
}
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
configs.push({
|
|
475
|
+
stateName: variant,
|
|
476
|
+
apiMocks,
|
|
477
|
+
authMock,
|
|
478
|
+
routeParams: output.routeParams
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
return configs;
|
|
482
|
+
}
|
|
483
|
+
function generateFallbackConfigs(analysis, variants) {
|
|
484
|
+
return variants.map((variant) => ({
|
|
485
|
+
stateName: variant,
|
|
486
|
+
apiMocks: {},
|
|
487
|
+
authMock: analysis.authConfig.hasAuth ? {
|
|
488
|
+
cookies: Object.fromEntries(
|
|
489
|
+
analysis.authConfig.cookieNames.map((name) => [name, "mock_token_c2d"])
|
|
490
|
+
),
|
|
491
|
+
authCheckResponse: variant === "error" ? { status: 401, body: { detail: "Unauthorized" } } : { status: 200, body: { id: "mock_user", email: "demo@c2d.dev", name: "Demo User" } }
|
|
492
|
+
} : null,
|
|
493
|
+
routeParams: analysis.route.isDynamic ? Object.fromEntries(analysis.route.params.map((p) => [p.name, "sample-1"])) : void 0
|
|
494
|
+
}));
|
|
495
|
+
}
|
|
496
|
+
async function generateMocks(analysis, options) {
|
|
497
|
+
const variants = options.variants ?? ALL_STATE_VARIANTS;
|
|
498
|
+
if (!analysis.hasApiDependencies) {
|
|
499
|
+
return {
|
|
500
|
+
configs: generateFallbackConfigs(analysis, variants),
|
|
501
|
+
tokenUsage: { input: 0, output: 0 }
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
const client = new LlmClient(options.apiKey, options.model);
|
|
505
|
+
const userPrompt = buildUserPrompt(analysis);
|
|
506
|
+
try {
|
|
507
|
+
const response = await client.generate(SYSTEM_PROMPT, userPrompt);
|
|
508
|
+
const parsed = parseLlmJson(response.content);
|
|
509
|
+
if (!parsed) {
|
|
510
|
+
console.warn(`[c2d] Failed to parse LLM response for ${analysis.route.urlPath}, using fallback`);
|
|
511
|
+
return {
|
|
512
|
+
configs: generateFallbackConfigs(analysis, variants),
|
|
513
|
+
tokenUsage: { input: response.inputTokens, output: response.outputTokens }
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
const configs = convertToMockConfigs(parsed, analysis, variants);
|
|
517
|
+
return {
|
|
518
|
+
configs,
|
|
519
|
+
tokenUsage: { input: response.inputTokens, output: response.outputTokens }
|
|
520
|
+
};
|
|
521
|
+
} catch (error2) {
|
|
522
|
+
console.warn(`[c2d] LLM call failed for ${analysis.route.urlPath}: ${error2}`);
|
|
523
|
+
return {
|
|
524
|
+
configs: generateFallbackConfigs(analysis, variants),
|
|
525
|
+
tokenUsage: { input: 0, output: 0 }
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// ../core/src/render/pre-renderer.ts
|
|
531
|
+
import { chromium } from "playwright";
|
|
532
|
+
import { mkdir, writeFile } from "fs/promises";
|
|
533
|
+
import { join as join5 } from "path";
|
|
534
|
+
|
|
535
|
+
// ../core/src/render/dev-server.ts
|
|
536
|
+
import { spawn } from "child_process";
|
|
537
|
+
import { existsSync as existsSync3 } from "fs";
|
|
538
|
+
import { join as join4 } from "path";
|
|
539
|
+
import { createServer } from "net";
|
|
540
|
+
async function findFreePort() {
|
|
541
|
+
return new Promise((resolve2, reject) => {
|
|
542
|
+
const server = createServer();
|
|
543
|
+
server.listen(0, () => {
|
|
544
|
+
const address = server.address();
|
|
545
|
+
if (address && typeof address === "object") {
|
|
546
|
+
const port = address.port;
|
|
547
|
+
server.close(() => resolve2(port));
|
|
548
|
+
} else {
|
|
549
|
+
server.close(() => reject(new Error("Could not find free port")));
|
|
550
|
+
}
|
|
551
|
+
});
|
|
552
|
+
server.on("error", reject);
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
async function waitForUrl(url, timeoutMs = 3e4) {
|
|
556
|
+
const start = Date.now();
|
|
557
|
+
while (Date.now() - start < timeoutMs) {
|
|
558
|
+
try {
|
|
559
|
+
const response = await fetch(url, { signal: AbortSignal.timeout(2e3) });
|
|
560
|
+
if (response.status > 0) return;
|
|
561
|
+
} catch {
|
|
562
|
+
}
|
|
563
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
564
|
+
}
|
|
565
|
+
throw new Error(`Dev server did not become ready at ${url} within ${timeoutMs}ms`);
|
|
566
|
+
}
|
|
567
|
+
function detectDevCommand(projectRoot) {
|
|
568
|
+
if (existsSync3(join4(projectRoot, "next.config.ts")) || existsSync3(join4(projectRoot, "next.config.js")) || existsSync3(join4(projectRoot, "next.config.mjs"))) {
|
|
569
|
+
return { cmd: "npx", args: ["next", "dev"] };
|
|
570
|
+
}
|
|
571
|
+
return { cmd: "npm", args: ["run", "dev"] };
|
|
572
|
+
}
|
|
573
|
+
async function startDevServer(projectRoot, options) {
|
|
574
|
+
if (options?.devServerUrl) {
|
|
575
|
+
await waitForUrl(options.devServerUrl, 1e4);
|
|
576
|
+
return {
|
|
577
|
+
url: options.devServerUrl,
|
|
578
|
+
port: 0,
|
|
579
|
+
process: null,
|
|
580
|
+
stop: async () => {
|
|
581
|
+
}
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
const port = options?.port ?? await findFreePort();
|
|
585
|
+
const { cmd, args } = detectDevCommand(projectRoot);
|
|
586
|
+
const fullArgs = [...args, "--port", String(port)];
|
|
587
|
+
const child = spawn(cmd, fullArgs, {
|
|
588
|
+
cwd: projectRoot,
|
|
589
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
590
|
+
env: { ...process.env, PORT: String(port) }
|
|
591
|
+
});
|
|
592
|
+
const url = `http://localhost:${port}`;
|
|
593
|
+
let stderr = "";
|
|
594
|
+
child.stderr?.on("data", (data) => {
|
|
595
|
+
stderr += data.toString();
|
|
596
|
+
});
|
|
597
|
+
const exitPromise = new Promise((_, reject) => {
|
|
598
|
+
child.on("exit", (code) => {
|
|
599
|
+
if (code !== null && code !== 0) {
|
|
600
|
+
reject(new Error(`Dev server exited with code ${code}.
|
|
601
|
+
${stderr.slice(-500)}`));
|
|
602
|
+
}
|
|
603
|
+
});
|
|
604
|
+
});
|
|
605
|
+
try {
|
|
606
|
+
await Promise.race([
|
|
607
|
+
waitForUrl(url, 6e4),
|
|
608
|
+
exitPromise
|
|
609
|
+
]);
|
|
610
|
+
} catch (err) {
|
|
611
|
+
child.kill("SIGTERM");
|
|
612
|
+
throw err;
|
|
613
|
+
}
|
|
614
|
+
return {
|
|
615
|
+
url,
|
|
616
|
+
port,
|
|
617
|
+
process: child,
|
|
618
|
+
stop: async () => {
|
|
619
|
+
if (child.exitCode === null) {
|
|
620
|
+
child.kill("SIGTERM");
|
|
621
|
+
await new Promise((resolve2) => {
|
|
622
|
+
const timer = setTimeout(() => {
|
|
623
|
+
child.kill("SIGKILL");
|
|
624
|
+
resolve2();
|
|
625
|
+
}, 5e3);
|
|
626
|
+
child.on("exit", () => {
|
|
627
|
+
clearTimeout(timer);
|
|
628
|
+
resolve2();
|
|
629
|
+
});
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// ../core/src/render/pre-renderer.ts
|
|
637
|
+
var DEFAULT_CONCURRENCY = 3;
|
|
638
|
+
var DEFAULT_PAGE_TIMEOUT = 15e3;
|
|
639
|
+
var DEFAULT_SETTLE_TIME = 1500;
|
|
640
|
+
var DEFAULT_VIEWPORT = { width: 1440, height: 900 };
|
|
641
|
+
function buildUrlPath(route, mockConfig) {
|
|
642
|
+
let path = route.urlPath;
|
|
643
|
+
if (route.isDynamic && mockConfig.routeParams) {
|
|
644
|
+
for (const param of route.params) {
|
|
645
|
+
const value = mockConfig.routeParams[param.name] || "sample-1";
|
|
646
|
+
path = path.replace(`:${param.name}`, value);
|
|
647
|
+
path = path.replace(`:${param.name}+`, value);
|
|
648
|
+
path = path.replace(`:${param.name}*`, value);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
return path;
|
|
652
|
+
}
|
|
653
|
+
function slugifyRoute(urlPath) {
|
|
654
|
+
if (urlPath === "/") return "index";
|
|
655
|
+
return urlPath.replace(/^\//, "").replace(/\//g, "-").replace(/:/g, "_").replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
656
|
+
}
|
|
657
|
+
async function setupMockInterception(page, devServerUrl, mockConfig, authConfig) {
|
|
658
|
+
if (mockConfig.authMock) {
|
|
659
|
+
const cookies = Object.entries(mockConfig.authMock.cookies).map(([name, value]) => ({
|
|
660
|
+
name,
|
|
661
|
+
value,
|
|
662
|
+
domain: new URL(devServerUrl).hostname,
|
|
663
|
+
path: "/"
|
|
664
|
+
}));
|
|
665
|
+
await page.context().addCookies(cookies);
|
|
666
|
+
} else if (authConfig.hasAuth && authConfig.cookieNames.length > 0) {
|
|
667
|
+
const cookies = authConfig.cookieNames.map((name) => ({
|
|
668
|
+
name,
|
|
669
|
+
value: "c2d_mock_token",
|
|
670
|
+
domain: new URL(devServerUrl).hostname,
|
|
671
|
+
path: "/"
|
|
672
|
+
}));
|
|
673
|
+
await page.context().addCookies(cookies);
|
|
674
|
+
}
|
|
675
|
+
const authMockBody = mockConfig.authMock?.authCheckResponse ? JSON.stringify(mockConfig.authMock.authCheckResponse.body) : JSON.stringify({ id: "mock_user", email: "demo@c2d.dev", name: "Demo User", email_verified: true, avatar_url: null, created_at: "2026-01-01T00:00:00Z" });
|
|
676
|
+
const AUTH_PATTERNS = ["/auth/me", "/auth/session", "/api/auth/me", "/api/user/me", "/api/auth/session"];
|
|
677
|
+
await page.route("**/*", async (route) => {
|
|
678
|
+
const request = route.request();
|
|
679
|
+
const url = request.url();
|
|
680
|
+
const method = request.method();
|
|
681
|
+
const isAuthCheck = AUTH_PATTERNS.some((p) => url.includes(p)) || authConfig.authCheckEndpoint && url.includes(authConfig.authCheckEndpoint);
|
|
682
|
+
if (isAuthCheck) {
|
|
683
|
+
const status = mockConfig.authMock?.authCheckResponse?.status ?? 200;
|
|
684
|
+
return route.fulfill({
|
|
685
|
+
status,
|
|
686
|
+
contentType: "application/json",
|
|
687
|
+
body: authMockBody
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
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)) {
|
|
697
|
+
if (mock.delay) await new Promise((r) => setTimeout(r, mock.delay));
|
|
698
|
+
return route.fulfill({
|
|
699
|
+
status: mock.status,
|
|
700
|
+
contentType: "application/json",
|
|
701
|
+
body: JSON.stringify(mock.body)
|
|
702
|
+
});
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
return route.continue();
|
|
706
|
+
});
|
|
707
|
+
}
|
|
708
|
+
async function renderPage(context, devServerUrl, task, outputDir, options, viewport) {
|
|
709
|
+
const { route, mockConfig, authConfig } = task;
|
|
710
|
+
const routeSlug = slugifyRoute(route.urlPath);
|
|
711
|
+
const stateDir = join5(outputDir, "renders", routeSlug);
|
|
712
|
+
await mkdir(stateDir, { recursive: true });
|
|
713
|
+
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);
|
|
718
|
+
const page = await context.newPage();
|
|
719
|
+
if (viewport) {
|
|
720
|
+
await page.setViewportSize({ width: viewport.width, height: viewport.height });
|
|
721
|
+
}
|
|
722
|
+
try {
|
|
723
|
+
await setupMockInterception(page, devServerUrl, mockConfig, authConfig);
|
|
724
|
+
const urlPath = buildUrlPath(route, mockConfig);
|
|
725
|
+
const fullUrl = `${devServerUrl}${urlPath}`;
|
|
726
|
+
await page.goto(fullUrl, {
|
|
727
|
+
waitUntil: "networkidle",
|
|
728
|
+
timeout: options.pageTimeout
|
|
729
|
+
});
|
|
730
|
+
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
|
+
})()`);
|
|
750
|
+
const html = await page.content();
|
|
751
|
+
await writeFile(htmlAbsPath, html, "utf-8");
|
|
752
|
+
await page.screenshot({ path: pngAbsPath, fullPage: true });
|
|
753
|
+
return {
|
|
754
|
+
route,
|
|
755
|
+
stateName: mockConfig.stateName,
|
|
756
|
+
htmlPath: htmlRelPath,
|
|
757
|
+
screenshotPath: pngRelPath,
|
|
758
|
+
success: true,
|
|
759
|
+
viewportName: viewport?.name
|
|
760
|
+
};
|
|
761
|
+
} catch (err) {
|
|
762
|
+
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
763
|
+
const errorHtml = `<!DOCTYPE html><html><body style="display:flex;align-items:center;justify-content:center;height:100vh;font-family:sans-serif;color:#666;">
|
|
764
|
+
<div style="text-align:center"><h2>Render Failed</h2><p>${route.urlPath} [${mockConfig.stateName}]</p><pre style="color:#c00">${errorMsg}</pre></div>
|
|
765
|
+
</body></html>`;
|
|
766
|
+
await writeFile(htmlAbsPath, errorHtml, "utf-8").catch(() => {
|
|
767
|
+
});
|
|
768
|
+
return {
|
|
769
|
+
route,
|
|
770
|
+
stateName: mockConfig.stateName,
|
|
771
|
+
htmlPath: htmlRelPath,
|
|
772
|
+
screenshotPath: pngRelPath,
|
|
773
|
+
success: false,
|
|
774
|
+
error: errorMsg,
|
|
775
|
+
viewportName: viewport?.name
|
|
776
|
+
};
|
|
777
|
+
} finally {
|
|
778
|
+
await page.close();
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
async function processWithConcurrency(items, concurrency, fn) {
|
|
782
|
+
const results = [];
|
|
783
|
+
const queue = [...items];
|
|
784
|
+
async function worker() {
|
|
785
|
+
while (queue.length > 0) {
|
|
786
|
+
const item = queue.shift();
|
|
787
|
+
const result = await fn(item);
|
|
788
|
+
results.push(result);
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
const workers = Array.from({ length: Math.min(concurrency, items.length) }, () => worker());
|
|
792
|
+
await Promise.all(workers);
|
|
793
|
+
return results;
|
|
794
|
+
}
|
|
795
|
+
function buildManifest(results, projectName, viewports) {
|
|
796
|
+
const routeMap = /* @__PURE__ */ new Map();
|
|
797
|
+
const viewportWidthMap = /* @__PURE__ */ new Map();
|
|
798
|
+
if (viewports) {
|
|
799
|
+
for (const vp of viewports) {
|
|
800
|
+
viewportWidthMap.set(vp.name, vp.width);
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
for (const result of results) {
|
|
804
|
+
const key = result.route.urlPath;
|
|
805
|
+
if (!routeMap.has(key)) {
|
|
806
|
+
routeMap.set(key, {
|
|
807
|
+
urlPath: result.route.urlPath,
|
|
808
|
+
filePath: result.route.filePath,
|
|
809
|
+
states: []
|
|
810
|
+
});
|
|
811
|
+
}
|
|
812
|
+
routeMap.get(key).states.push({
|
|
813
|
+
name: result.viewportName ? `${result.stateName} (${result.viewportName})` : result.stateName,
|
|
814
|
+
htmlPath: result.htmlPath,
|
|
815
|
+
screenshotPath: result.screenshotPath,
|
|
816
|
+
status: result.success ? "ok" : "error",
|
|
817
|
+
error: result.error,
|
|
818
|
+
viewport: result.viewportName,
|
|
819
|
+
viewportWidth: result.viewportName ? viewportWidthMap.get(result.viewportName) : void 0
|
|
820
|
+
});
|
|
821
|
+
}
|
|
822
|
+
return {
|
|
823
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
824
|
+
projectName,
|
|
825
|
+
routes: [...routeMap.values()].sort((a, b) => a.urlPath.localeCompare(b.urlPath))
|
|
826
|
+
};
|
|
827
|
+
}
|
|
828
|
+
async function preRenderPages(tasks, options) {
|
|
829
|
+
const {
|
|
830
|
+
projectRoot,
|
|
831
|
+
outputDir = join5(projectRoot, ".c2d"),
|
|
832
|
+
concurrency = DEFAULT_CONCURRENCY,
|
|
833
|
+
pageTimeout = DEFAULT_PAGE_TIMEOUT,
|
|
834
|
+
settleTime = DEFAULT_SETTLE_TIME,
|
|
835
|
+
viewportWidth = DEFAULT_VIEWPORT.width,
|
|
836
|
+
viewportHeight = DEFAULT_VIEWPORT.height
|
|
837
|
+
} = options;
|
|
838
|
+
const viewports = options.viewports;
|
|
839
|
+
const useMultiViewport = viewports && viewports.length > 0;
|
|
840
|
+
await mkdir(outputDir, { recursive: true });
|
|
841
|
+
let devServer = null;
|
|
842
|
+
try {
|
|
843
|
+
devServer = await startDevServer(projectRoot, {
|
|
844
|
+
port: options.devServerPort,
|
|
845
|
+
devServerUrl: options.devServerUrl
|
|
846
|
+
});
|
|
847
|
+
const browser = await chromium.launch({ headless: true });
|
|
848
|
+
const context = await browser.newContext({
|
|
849
|
+
viewport: { width: viewportWidth, height: viewportHeight }
|
|
850
|
+
});
|
|
851
|
+
try {
|
|
852
|
+
let results;
|
|
853
|
+
if (useMultiViewport) {
|
|
854
|
+
const expandedItems = [];
|
|
855
|
+
for (const task of tasks) {
|
|
856
|
+
for (const vp of viewports) {
|
|
857
|
+
expandedItems.push({ task, viewport: vp });
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
results = await processWithConcurrency(
|
|
861
|
+
expandedItems,
|
|
862
|
+
concurrency,
|
|
863
|
+
({ task, viewport: vp }) => renderPage(context, devServer.url, task, outputDir, { pageTimeout, settleTime }, vp)
|
|
864
|
+
);
|
|
865
|
+
} else {
|
|
866
|
+
results = await processWithConcurrency(
|
|
867
|
+
tasks,
|
|
868
|
+
concurrency,
|
|
869
|
+
(task) => renderPage(context, devServer.url, task, outputDir, { pageTimeout, settleTime })
|
|
870
|
+
);
|
|
871
|
+
}
|
|
872
|
+
let projectName = "unknown";
|
|
873
|
+
try {
|
|
874
|
+
const pkg = await import(join5(projectRoot, "package.json"), { with: { type: "json" } });
|
|
875
|
+
projectName = pkg.default?.name || "unknown";
|
|
876
|
+
} catch {
|
|
877
|
+
}
|
|
878
|
+
const manifest = buildManifest(
|
|
879
|
+
results,
|
|
880
|
+
projectName,
|
|
881
|
+
useMultiViewport ? viewports : void 0
|
|
882
|
+
);
|
|
883
|
+
await writeFile(
|
|
884
|
+
join5(outputDir, "manifest.json"),
|
|
885
|
+
JSON.stringify(manifest, null, 2),
|
|
886
|
+
"utf-8"
|
|
887
|
+
);
|
|
888
|
+
return { results, manifest };
|
|
889
|
+
} finally {
|
|
890
|
+
await context.close();
|
|
891
|
+
await browser.close();
|
|
892
|
+
}
|
|
893
|
+
} finally {
|
|
894
|
+
if (devServer) {
|
|
895
|
+
await devServer.stop();
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
// src/server/canvas-server.ts
|
|
901
|
+
import { createServer as createServer2 } from "http";
|
|
902
|
+
import { join as join7 } from "path";
|
|
903
|
+
import sirv from "sirv";
|
|
904
|
+
|
|
905
|
+
// src/server/api-routes.ts
|
|
906
|
+
import { readFile as readFile3, writeFile as writeFile2, mkdir as mkdir2 } from "fs/promises";
|
|
907
|
+
import { existsSync as existsSync4 } from "fs";
|
|
908
|
+
import { join as join6, dirname as dirname2 } from "path";
|
|
909
|
+
import { randomUUID } from "crypto";
|
|
910
|
+
async function handleApiRequest(req, res, c2dDir) {
|
|
911
|
+
const url = req.url ?? "/";
|
|
912
|
+
const method = req.method ?? "GET";
|
|
913
|
+
if (url === "/api/manifest" && method === "GET") {
|
|
914
|
+
await handleGetManifest(res, c2dDir);
|
|
915
|
+
return true;
|
|
916
|
+
}
|
|
917
|
+
if (url === "/api/comments" && method === "GET") {
|
|
918
|
+
await handleGetComments(res, c2dDir);
|
|
919
|
+
return true;
|
|
920
|
+
}
|
|
921
|
+
if (url === "/api/comments" && method === "POST") {
|
|
922
|
+
await handlePostComment(req, res, c2dDir);
|
|
923
|
+
return true;
|
|
924
|
+
}
|
|
925
|
+
const deleteMatch = url.match(/^\/api\/comments\/(.+)$/);
|
|
926
|
+
if (deleteMatch && method === "DELETE") {
|
|
927
|
+
await handleDeleteComment(res, c2dDir, deleteMatch[1]);
|
|
928
|
+
return true;
|
|
929
|
+
}
|
|
930
|
+
if (url === "/api/drawings" && method === "GET") {
|
|
931
|
+
await handleGetDrawings(res, c2dDir);
|
|
932
|
+
return true;
|
|
933
|
+
}
|
|
934
|
+
if (url === "/api/drawings" && method === "POST") {
|
|
935
|
+
await handlePostDrawings(req, res, c2dDir);
|
|
936
|
+
return true;
|
|
937
|
+
}
|
|
938
|
+
return false;
|
|
939
|
+
}
|
|
940
|
+
async function handleGetManifest(res, c2dDir) {
|
|
941
|
+
const manifestPath = join6(c2dDir, "manifest.json");
|
|
942
|
+
if (!existsSync4(manifestPath)) {
|
|
943
|
+
sendJson(res, 404, { error: "manifest.json not found" });
|
|
944
|
+
return;
|
|
945
|
+
}
|
|
946
|
+
const data = await readFile3(manifestPath, "utf-8");
|
|
947
|
+
sendRawJson(res, 200, data);
|
|
948
|
+
}
|
|
949
|
+
async function handleGetComments(res, c2dDir) {
|
|
950
|
+
const comments = await loadComments(c2dDir);
|
|
951
|
+
sendJson(res, 200, comments);
|
|
952
|
+
}
|
|
953
|
+
async function handlePostComment(req, res, c2dDir) {
|
|
954
|
+
const body = await readRequestBody(req);
|
|
955
|
+
let parsed;
|
|
956
|
+
try {
|
|
957
|
+
parsed = JSON.parse(body);
|
|
958
|
+
} catch {
|
|
959
|
+
sendJson(res, 400, { error: "Invalid JSON body" });
|
|
960
|
+
return;
|
|
961
|
+
}
|
|
962
|
+
if (!isValidCommentInput(parsed)) {
|
|
963
|
+
sendJson(res, 400, { error: "Missing required fields: x, y, text, author" });
|
|
964
|
+
return;
|
|
965
|
+
}
|
|
966
|
+
const comment = {
|
|
967
|
+
id: randomUUID(),
|
|
968
|
+
x: parsed.x,
|
|
969
|
+
y: parsed.y,
|
|
970
|
+
text: parsed.text,
|
|
971
|
+
author: parsed.author,
|
|
972
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
973
|
+
};
|
|
974
|
+
const comments = await loadComments(c2dDir);
|
|
975
|
+
comments.push(comment);
|
|
976
|
+
await saveComments(c2dDir, comments);
|
|
977
|
+
sendJson(res, 201, comment);
|
|
978
|
+
}
|
|
979
|
+
async function handleDeleteComment(res, c2dDir, id) {
|
|
980
|
+
const comments = await loadComments(c2dDir);
|
|
981
|
+
const index = comments.findIndex((c) => c.id === id);
|
|
982
|
+
if (index === -1) {
|
|
983
|
+
sendJson(res, 404, { error: "Comment not found" });
|
|
984
|
+
return;
|
|
985
|
+
}
|
|
986
|
+
comments.splice(index, 1);
|
|
987
|
+
await saveComments(c2dDir, comments);
|
|
988
|
+
sendJson(res, 200, { ok: true });
|
|
989
|
+
}
|
|
990
|
+
async function handleGetDrawings(res, c2dDir) {
|
|
991
|
+
const drawings = await loadDrawings(c2dDir);
|
|
992
|
+
sendJson(res, 200, drawings);
|
|
993
|
+
}
|
|
994
|
+
async function handlePostDrawings(req, res, c2dDir) {
|
|
995
|
+
const body = await readRequestBody(req);
|
|
996
|
+
let parsed;
|
|
997
|
+
try {
|
|
998
|
+
parsed = JSON.parse(body);
|
|
999
|
+
} catch {
|
|
1000
|
+
sendJson(res, 400, { error: "Invalid JSON body" });
|
|
1001
|
+
return;
|
|
1002
|
+
}
|
|
1003
|
+
if (!Array.isArray(parsed)) {
|
|
1004
|
+
sendJson(res, 400, { error: "Body must be an array of strokes" });
|
|
1005
|
+
return;
|
|
1006
|
+
}
|
|
1007
|
+
await saveDrawings(c2dDir, parsed);
|
|
1008
|
+
sendJson(res, 200, { ok: true });
|
|
1009
|
+
}
|
|
1010
|
+
async function loadDrawings(c2dDir) {
|
|
1011
|
+
const drawingsPath = join6(c2dDir, "drawings.json");
|
|
1012
|
+
if (!existsSync4(drawingsPath)) {
|
|
1013
|
+
return [];
|
|
1014
|
+
}
|
|
1015
|
+
const data = await readFile3(drawingsPath, "utf-8");
|
|
1016
|
+
try {
|
|
1017
|
+
const parsed = JSON.parse(data);
|
|
1018
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
1019
|
+
} catch {
|
|
1020
|
+
return [];
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
async function saveDrawings(c2dDir, drawings) {
|
|
1024
|
+
const drawingsPath = join6(c2dDir, "drawings.json");
|
|
1025
|
+
const dir = dirname2(drawingsPath);
|
|
1026
|
+
if (!existsSync4(dir)) {
|
|
1027
|
+
await mkdir2(dir, { recursive: true });
|
|
1028
|
+
}
|
|
1029
|
+
await writeFile2(drawingsPath, JSON.stringify(drawings, null, 2), "utf-8");
|
|
1030
|
+
}
|
|
1031
|
+
function isValidCommentInput(value) {
|
|
1032
|
+
if (typeof value !== "object" || value === null) return false;
|
|
1033
|
+
const obj = value;
|
|
1034
|
+
return typeof obj.x === "number" && typeof obj.y === "number" && typeof obj.text === "string" && typeof obj.author === "string";
|
|
1035
|
+
}
|
|
1036
|
+
async function loadComments(c2dDir) {
|
|
1037
|
+
const commentsPath = join6(c2dDir, "comments.json");
|
|
1038
|
+
if (!existsSync4(commentsPath)) {
|
|
1039
|
+
return [];
|
|
1040
|
+
}
|
|
1041
|
+
const data = await readFile3(commentsPath, "utf-8");
|
|
1042
|
+
try {
|
|
1043
|
+
const parsed = JSON.parse(data);
|
|
1044
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
1045
|
+
} catch {
|
|
1046
|
+
return [];
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
async function saveComments(c2dDir, comments) {
|
|
1050
|
+
const commentsPath = join6(c2dDir, "comments.json");
|
|
1051
|
+
const dir = dirname2(commentsPath);
|
|
1052
|
+
if (!existsSync4(dir)) {
|
|
1053
|
+
await mkdir2(dir, { recursive: true });
|
|
1054
|
+
}
|
|
1055
|
+
await writeFile2(commentsPath, JSON.stringify(comments, null, 2), "utf-8");
|
|
1056
|
+
}
|
|
1057
|
+
function readRequestBody(req) {
|
|
1058
|
+
return new Promise((resolve2, reject) => {
|
|
1059
|
+
const chunks = [];
|
|
1060
|
+
req.on("data", (chunk) => chunks.push(chunk));
|
|
1061
|
+
req.on("end", () => resolve2(Buffer.concat(chunks).toString("utf-8")));
|
|
1062
|
+
req.on("error", reject);
|
|
1063
|
+
});
|
|
1064
|
+
}
|
|
1065
|
+
function sendJson(res, status, data) {
|
|
1066
|
+
const body = JSON.stringify(data);
|
|
1067
|
+
res.writeHead(status, {
|
|
1068
|
+
"Content-Type": "application/json",
|
|
1069
|
+
"Access-Control-Allow-Origin": "*"
|
|
1070
|
+
});
|
|
1071
|
+
res.end(body);
|
|
1072
|
+
}
|
|
1073
|
+
function sendRawJson(res, status, jsonString) {
|
|
1074
|
+
res.writeHead(status, {
|
|
1075
|
+
"Content-Type": "application/json",
|
|
1076
|
+
"Access-Control-Allow-Origin": "*"
|
|
1077
|
+
});
|
|
1078
|
+
res.end(jsonString);
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
// src/server/canvas-server.ts
|
|
1082
|
+
async function startCanvasServer(options) {
|
|
1083
|
+
const { port = 4800, canvasDir, c2dDir } = options;
|
|
1084
|
+
const canvasHandler = sirv(canvasDir, { single: true, dev: true });
|
|
1085
|
+
const rendersDir = join7(c2dDir, "renders");
|
|
1086
|
+
const rendersHandler = sirv(rendersDir, { dev: true });
|
|
1087
|
+
const server = createServer2(async (req, res) => {
|
|
1088
|
+
const url = req.url ?? "/";
|
|
1089
|
+
const method = req.method ?? "GET";
|
|
1090
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
1091
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
|
|
1092
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
|
1093
|
+
if (method === "OPTIONS") {
|
|
1094
|
+
res.writeHead(204);
|
|
1095
|
+
res.end();
|
|
1096
|
+
return;
|
|
1097
|
+
}
|
|
1098
|
+
if (url.startsWith("/api/")) {
|
|
1099
|
+
try {
|
|
1100
|
+
const handled = await handleApiRequest(req, res, c2dDir);
|
|
1101
|
+
if (!handled) {
|
|
1102
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
1103
|
+
res.end(JSON.stringify({ error: "Not found" }));
|
|
1104
|
+
}
|
|
1105
|
+
} catch (err) {
|
|
1106
|
+
const message = err instanceof Error ? err.message : "Internal server error";
|
|
1107
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
1108
|
+
res.end(JSON.stringify({ error: message }));
|
|
1109
|
+
}
|
|
1110
|
+
return;
|
|
1111
|
+
}
|
|
1112
|
+
if (url.startsWith("/renders/")) {
|
|
1113
|
+
req.url = url.slice("/renders".length) || "/";
|
|
1114
|
+
rendersHandler(req, res, () => {
|
|
1115
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
1116
|
+
res.end(JSON.stringify({ error: "Render not found" }));
|
|
1117
|
+
});
|
|
1118
|
+
return;
|
|
1119
|
+
}
|
|
1120
|
+
canvasHandler(req, res, () => {
|
|
1121
|
+
res.writeHead(404);
|
|
1122
|
+
res.end("Not found");
|
|
1123
|
+
});
|
|
1124
|
+
});
|
|
1125
|
+
return new Promise((resolve2, reject) => {
|
|
1126
|
+
server.on("error", reject);
|
|
1127
|
+
server.listen(port, () => {
|
|
1128
|
+
const actualPort = server.address().port;
|
|
1129
|
+
resolve2({
|
|
1130
|
+
url: `http://localhost:${actualPort}`,
|
|
1131
|
+
port: actualPort,
|
|
1132
|
+
close: () => new Promise((resolveClose, rejectClose) => {
|
|
1133
|
+
server.close((err) => {
|
|
1134
|
+
if (err) rejectClose(err);
|
|
1135
|
+
else resolveClose();
|
|
1136
|
+
});
|
|
1137
|
+
})
|
|
1138
|
+
});
|
|
1139
|
+
});
|
|
1140
|
+
});
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
// src/config.ts
|
|
1144
|
+
import { existsSync as existsSync5 } from "fs";
|
|
1145
|
+
import { readFile as readFile4 } from "fs/promises";
|
|
1146
|
+
import { join as join8 } from "path";
|
|
1147
|
+
var DEFAULT_PORT = 4800;
|
|
1148
|
+
async function loadConfig(projectRoot) {
|
|
1149
|
+
let fileConfig = {};
|
|
1150
|
+
const configPath = join8(projectRoot, "c2d.config.js");
|
|
1151
|
+
if (existsSync5(configPath)) {
|
|
1152
|
+
try {
|
|
1153
|
+
const mod = await import(configPath);
|
|
1154
|
+
fileConfig = mod.default || mod;
|
|
1155
|
+
} catch {
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
const apiKey = process.env.C2D_API_KEY || fileConfig.apiKey || "";
|
|
1159
|
+
return {
|
|
1160
|
+
apiKey,
|
|
1161
|
+
port: fileConfig.port || Number(process.env.C2D_PORT) || DEFAULT_PORT,
|
|
1162
|
+
excludeRoutes: fileConfig.excludeRoutes || [],
|
|
1163
|
+
devServerUrl: fileConfig.devServerUrl || process.env.C2D_DEV_SERVER_URL,
|
|
1164
|
+
devServerCommand: fileConfig.devServerCommand
|
|
1165
|
+
};
|
|
1166
|
+
}
|
|
1167
|
+
async function detectNextJsProject(projectRoot) {
|
|
1168
|
+
const hasNextConfig = existsSync5(join8(projectRoot, "next.config.ts")) || existsSync5(join8(projectRoot, "next.config.js")) || existsSync5(join8(projectRoot, "next.config.mjs"));
|
|
1169
|
+
const appDir = join8(projectRoot, "app");
|
|
1170
|
+
const hasAppDir = existsSync5(appDir);
|
|
1171
|
+
let projectName = "unknown";
|
|
1172
|
+
try {
|
|
1173
|
+
const pkg = JSON.parse(await readFile4(join8(projectRoot, "package.json"), "utf-8"));
|
|
1174
|
+
projectName = pkg.name || "unknown";
|
|
1175
|
+
} catch {
|
|
1176
|
+
}
|
|
1177
|
+
return {
|
|
1178
|
+
isNextJs: hasNextConfig,
|
|
1179
|
+
appDir: hasAppDir ? appDir : null,
|
|
1180
|
+
projectName
|
|
1181
|
+
};
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
// src/utils/progress.ts
|
|
1185
|
+
var COLORS = {
|
|
1186
|
+
reset: "\x1B[0m",
|
|
1187
|
+
bold: "\x1B[1m",
|
|
1188
|
+
dim: "\x1B[2m",
|
|
1189
|
+
green: "\x1B[32m",
|
|
1190
|
+
yellow: "\x1B[33m",
|
|
1191
|
+
blue: "\x1B[34m",
|
|
1192
|
+
red: "\x1B[31m",
|
|
1193
|
+
cyan: "\x1B[36m"
|
|
1194
|
+
};
|
|
1195
|
+
function log(message) {
|
|
1196
|
+
console.log(`${COLORS.dim}[c2d]${COLORS.reset} ${message}`);
|
|
1197
|
+
}
|
|
1198
|
+
function success(message) {
|
|
1199
|
+
console.log(`${COLORS.green} \u2713${COLORS.reset} ${message}`);
|
|
1200
|
+
}
|
|
1201
|
+
function warn(message) {
|
|
1202
|
+
console.log(`${COLORS.yellow} \u26A0${COLORS.reset} ${message}`);
|
|
1203
|
+
}
|
|
1204
|
+
function error(message) {
|
|
1205
|
+
console.error(`${COLORS.red} \u2717${COLORS.reset} ${message}`);
|
|
1206
|
+
}
|
|
1207
|
+
function header(message) {
|
|
1208
|
+
console.log(`
|
|
1209
|
+
${COLORS.bold}${COLORS.cyan}${message}${COLORS.reset}`);
|
|
1210
|
+
}
|
|
1211
|
+
function step(current, total, message) {
|
|
1212
|
+
console.log(`${COLORS.dim} [${current}/${total}]${COLORS.reset} ${message}`);
|
|
1213
|
+
}
|
|
1214
|
+
function banner() {
|
|
1215
|
+
console.log(`
|
|
1216
|
+
${COLORS.bold}${COLORS.cyan} Code to Design${COLORS.reset} ${COLORS.dim}v0.1.0${COLORS.reset}
|
|
1217
|
+
${COLORS.dim} AI-powered UI review canvas${COLORS.reset}
|
|
1218
|
+
`);
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
// src/commands/scan.ts
|
|
1222
|
+
async function runScan(options) {
|
|
1223
|
+
const { projectRoot, skipRender = false, open = true, watch = false } = options;
|
|
1224
|
+
banner();
|
|
1225
|
+
const config = await loadConfig(projectRoot);
|
|
1226
|
+
header("Detecting project...");
|
|
1227
|
+
const project = await detectNextJsProject(projectRoot);
|
|
1228
|
+
if (!project.isNextJs) {
|
|
1229
|
+
error("Not a Next.js project (no next.config.ts/js/mjs found).");
|
|
1230
|
+
log("Code to Design currently supports Next.js App Router projects only.");
|
|
1231
|
+
process.exit(1);
|
|
1232
|
+
}
|
|
1233
|
+
if (!project.appDir) {
|
|
1234
|
+
error("No app/ directory found. Code to Design requires Next.js App Router.");
|
|
1235
|
+
process.exit(1);
|
|
1236
|
+
}
|
|
1237
|
+
success(`Project: ${project.projectName}`);
|
|
1238
|
+
success(`App directory: ${project.appDir}`);
|
|
1239
|
+
const c2dDir = join9(projectRoot, ".c2d");
|
|
1240
|
+
if (skipRender) {
|
|
1241
|
+
if (!existsSync6(join9(c2dDir, "manifest.json"))) {
|
|
1242
|
+
error("No previous renders found. Run without --skip-render first.");
|
|
1243
|
+
process.exit(1);
|
|
1244
|
+
}
|
|
1245
|
+
log("Skipping render, using existing canvas data...");
|
|
1246
|
+
await startServer(c2dDir, config.port, open);
|
|
1247
|
+
return;
|
|
1248
|
+
}
|
|
1249
|
+
header("Discovering routes...");
|
|
1250
|
+
const routes = await scanRoutes({ appDir: project.appDir });
|
|
1251
|
+
if (routes.length === 0) {
|
|
1252
|
+
error("No routes found in app/ directory.");
|
|
1253
|
+
process.exit(1);
|
|
1254
|
+
}
|
|
1255
|
+
const filteredRoutes = routes.filter(
|
|
1256
|
+
(r) => !config.excludeRoutes.some((pattern) => r.urlPath.includes(pattern))
|
|
1257
|
+
);
|
|
1258
|
+
success(`Found ${filteredRoutes.length} routes`);
|
|
1259
|
+
for (const r of filteredRoutes) {
|
|
1260
|
+
log(` ${r.urlPath}${r.isDynamic ? " [dynamic]" : ""}`);
|
|
1261
|
+
}
|
|
1262
|
+
if (!config.apiKey) {
|
|
1263
|
+
warn("No API key configured. Mock generation will use fallback data.");
|
|
1264
|
+
log("Set C2D_API_KEY env var or add apiKey to c2d.config.js");
|
|
1265
|
+
}
|
|
1266
|
+
header("Analyzing code and generating mocks...");
|
|
1267
|
+
const renderTasks = [];
|
|
1268
|
+
let totalTokens = { input: 0, output: 0 };
|
|
1269
|
+
const mockOptions = {
|
|
1270
|
+
apiKey: config.apiKey
|
|
1271
|
+
};
|
|
1272
|
+
for (let i = 0; i < filteredRoutes.length; i++) {
|
|
1273
|
+
const route = filteredRoutes[i];
|
|
1274
|
+
step(i + 1, filteredRoutes.length, `${route.urlPath}`);
|
|
1275
|
+
const analysis = await analyzePage(route, { projectRoot });
|
|
1276
|
+
const { configs, tokenUsage } = await generateMocks(analysis, mockOptions);
|
|
1277
|
+
totalTokens.input += tokenUsage.input;
|
|
1278
|
+
totalTokens.output += tokenUsage.output;
|
|
1279
|
+
for (const mockConfig of configs) {
|
|
1280
|
+
renderTasks.push({
|
|
1281
|
+
route,
|
|
1282
|
+
mockConfig,
|
|
1283
|
+
authConfig: analysis.authConfig
|
|
1284
|
+
});
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
if (totalTokens.input > 0) {
|
|
1288
|
+
success(`Mock generation complete (${totalTokens.input} input tokens, ${totalTokens.output} output tokens)`);
|
|
1289
|
+
} else {
|
|
1290
|
+
success("Using fallback mocks (no API key or no API dependencies)");
|
|
1291
|
+
}
|
|
1292
|
+
header(`Pre-rendering ${renderTasks.length} page states...`);
|
|
1293
|
+
if (existsSync6(join9(c2dDir, "renders"))) {
|
|
1294
|
+
await rm(join9(c2dDir, "renders"), { recursive: true });
|
|
1295
|
+
}
|
|
1296
|
+
await mkdir3(c2dDir, { recursive: true });
|
|
1297
|
+
const { results, manifest } = await preRenderPages(renderTasks, {
|
|
1298
|
+
projectRoot,
|
|
1299
|
+
outputDir: c2dDir,
|
|
1300
|
+
devServerUrl: config.devServerUrl
|
|
1301
|
+
});
|
|
1302
|
+
const successCount = results.filter((r) => r.success).length;
|
|
1303
|
+
const failCount = results.filter((r) => !r.success).length;
|
|
1304
|
+
success(`Rendered ${successCount}/${results.length} pages`);
|
|
1305
|
+
if (failCount > 0) {
|
|
1306
|
+
warn(`${failCount} pages failed to render`);
|
|
1307
|
+
for (const r of results.filter((r2) => !r2.success)) {
|
|
1308
|
+
error(` ${r.route.urlPath} [${r.stateName}]: ${r.error}`);
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
if (watch) {
|
|
1312
|
+
const server = await startServerNonBlocking(c2dDir, config.port, open);
|
|
1313
|
+
watchAndRerender(projectRoot, project.appDir, c2dDir, config);
|
|
1314
|
+
await new Promise((resolve2) => {
|
|
1315
|
+
const shutdown = async () => {
|
|
1316
|
+
log("\nShutting down...");
|
|
1317
|
+
await server.close();
|
|
1318
|
+
resolve2();
|
|
1319
|
+
};
|
|
1320
|
+
process.on("SIGINT", shutdown);
|
|
1321
|
+
process.on("SIGTERM", shutdown);
|
|
1322
|
+
});
|
|
1323
|
+
} else {
|
|
1324
|
+
await startServer(c2dDir, config.port, open);
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
async function startServer(c2dDir, port, open) {
|
|
1328
|
+
header("Starting canvas server...");
|
|
1329
|
+
const canvasDir = await resolveCanvasDir(c2dDir);
|
|
1330
|
+
if (!canvasDir) {
|
|
1331
|
+
warn("Canvas app not bundled. Using placeholder.");
|
|
1332
|
+
}
|
|
1333
|
+
const server = await startCanvasServer({
|
|
1334
|
+
port,
|
|
1335
|
+
canvasDir,
|
|
1336
|
+
c2dDir
|
|
1337
|
+
});
|
|
1338
|
+
success(`Canvas server running at ${server.url}`);
|
|
1339
|
+
log("Share this URL with your team for collaborative review");
|
|
1340
|
+
log("Press Ctrl+C to stop\n");
|
|
1341
|
+
if (open) {
|
|
1342
|
+
try {
|
|
1343
|
+
const { exec } = await import("child_process");
|
|
1344
|
+
exec(`open ${server.url}`);
|
|
1345
|
+
} catch {
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
await new Promise((resolve2) => {
|
|
1349
|
+
const shutdown = async () => {
|
|
1350
|
+
log("\nShutting down...");
|
|
1351
|
+
await server.close();
|
|
1352
|
+
resolve2();
|
|
1353
|
+
};
|
|
1354
|
+
process.on("SIGINT", shutdown);
|
|
1355
|
+
process.on("SIGTERM", shutdown);
|
|
1356
|
+
});
|
|
1357
|
+
}
|
|
1358
|
+
async function startServerNonBlocking(c2dDir, port, open) {
|
|
1359
|
+
header("Starting canvas server...");
|
|
1360
|
+
const canvasDir = await resolveCanvasDir(c2dDir);
|
|
1361
|
+
if (!canvasDir) {
|
|
1362
|
+
warn("Canvas app not bundled. Using placeholder.");
|
|
1363
|
+
}
|
|
1364
|
+
const server = await startCanvasServer({
|
|
1365
|
+
port,
|
|
1366
|
+
canvasDir,
|
|
1367
|
+
c2dDir
|
|
1368
|
+
});
|
|
1369
|
+
success(`Canvas server running at ${server.url}`);
|
|
1370
|
+
log("Share this URL with your team for collaborative review");
|
|
1371
|
+
log("Watching for file changes...\n");
|
|
1372
|
+
if (open) {
|
|
1373
|
+
try {
|
|
1374
|
+
const { exec } = await import("child_process");
|
|
1375
|
+
exec(`open ${server.url}`);
|
|
1376
|
+
} catch {
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
return server;
|
|
1380
|
+
}
|
|
1381
|
+
async function resolveCanvasDir(c2dDir) {
|
|
1382
|
+
const { fileURLToPath } = await import("url");
|
|
1383
|
+
const { dirname: dirname3 } = await import("path");
|
|
1384
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
1385
|
+
let dir = dirname3(__filename);
|
|
1386
|
+
for (let i = 0; i < 5; i++) {
|
|
1387
|
+
const candidate = join9(dir, "canvas-dist");
|
|
1388
|
+
if (existsSync6(candidate) && existsSync6(join9(candidate, "index.html"))) {
|
|
1389
|
+
return candidate;
|
|
1390
|
+
}
|
|
1391
|
+
dir = dirname3(dir);
|
|
1392
|
+
}
|
|
1393
|
+
const monorepoDev = join9(dirname3(__filename), "..", "..", "..", "..", "apps", "canvas", "dist");
|
|
1394
|
+
if (existsSync6(monorepoDev) && existsSync6(join9(monorepoDev, "index.html"))) {
|
|
1395
|
+
return monorepoDev;
|
|
1396
|
+
}
|
|
1397
|
+
const placeholder = join9(c2dDir, "_canvas");
|
|
1398
|
+
await mkdir3(placeholder, { recursive: true });
|
|
1399
|
+
const { writeFile: writeFile3 } = await import("fs/promises");
|
|
1400
|
+
await writeFile3(join9(placeholder, "index.html"), `<!DOCTYPE html><html><body>
|
|
1401
|
+
<h1>Code to Design</h1>
|
|
1402
|
+
<p>Canvas app not built. Run: <code>cd apps/canvas && npx vite build</code></p>
|
|
1403
|
+
<p><a href="/api/manifest">View Manifest</a></p>
|
|
1404
|
+
</body></html>`);
|
|
1405
|
+
return placeholder;
|
|
1406
|
+
}
|
|
1407
|
+
var WATCH_EXTENSIONS = /* @__PURE__ */ new Set([".tsx", ".ts", ".jsx", ".js", ".css"]);
|
|
1408
|
+
function shouldIgnoreFile(filename) {
|
|
1409
|
+
if (!filename) return true;
|
|
1410
|
+
const ignored = ["node_modules", ".next", ".c2d", ".git"];
|
|
1411
|
+
if (ignored.some((dir) => filename.includes(dir))) return true;
|
|
1412
|
+
const ext = filename.slice(filename.lastIndexOf("."));
|
|
1413
|
+
return !WATCH_EXTENSIONS.has(ext);
|
|
1414
|
+
}
|
|
1415
|
+
function watchAndRerender(projectRoot, appDir, c2dDir, config) {
|
|
1416
|
+
let debounceTimer;
|
|
1417
|
+
let isRendering = false;
|
|
1418
|
+
log(`Watching ${appDir} for changes...`);
|
|
1419
|
+
fsWatch(appDir, { recursive: true }, (_event, filename) => {
|
|
1420
|
+
if (shouldIgnoreFile(filename)) return;
|
|
1421
|
+
clearTimeout(debounceTimer);
|
|
1422
|
+
debounceTimer = setTimeout(async () => {
|
|
1423
|
+
if (isRendering) return;
|
|
1424
|
+
isRendering = true;
|
|
1425
|
+
log(`
|
|
1426
|
+
File changed: ${filename}. Re-rendering...`);
|
|
1427
|
+
try {
|
|
1428
|
+
const routes = await scanRoutes({ appDir });
|
|
1429
|
+
const filteredRoutes = routes.filter(
|
|
1430
|
+
(r) => !config.excludeRoutes.some((pattern) => r.urlPath.includes(pattern))
|
|
1431
|
+
);
|
|
1432
|
+
const renderTasks = [];
|
|
1433
|
+
const mockOptions = { apiKey: config.apiKey };
|
|
1434
|
+
for (const route of filteredRoutes) {
|
|
1435
|
+
const analysis = await analyzePage(route, { projectRoot });
|
|
1436
|
+
const { configs } = await generateMocks(analysis, mockOptions);
|
|
1437
|
+
for (const mockConfig of configs) {
|
|
1438
|
+
renderTasks.push({
|
|
1439
|
+
route,
|
|
1440
|
+
mockConfig,
|
|
1441
|
+
authConfig: analysis.authConfig
|
|
1442
|
+
});
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1445
|
+
if (existsSync6(join9(c2dDir, "renders"))) {
|
|
1446
|
+
await rm(join9(c2dDir, "renders"), { recursive: true });
|
|
1447
|
+
}
|
|
1448
|
+
await mkdir3(c2dDir, { recursive: true });
|
|
1449
|
+
const { results } = await preRenderPages(renderTasks, {
|
|
1450
|
+
projectRoot,
|
|
1451
|
+
outputDir: c2dDir,
|
|
1452
|
+
devServerUrl: config.devServerUrl
|
|
1453
|
+
});
|
|
1454
|
+
const successCount = results.filter((r) => r.success).length;
|
|
1455
|
+
success(`Re-render complete. ${successCount} pages updated.`);
|
|
1456
|
+
} catch (err) {
|
|
1457
|
+
error(`Re-render failed: ${err.message || err}`);
|
|
1458
|
+
} finally {
|
|
1459
|
+
isRendering = false;
|
|
1460
|
+
}
|
|
1461
|
+
}, 500);
|
|
1462
|
+
});
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
export {
|
|
1466
|
+
startCanvasServer,
|
|
1467
|
+
loadConfig,
|
|
1468
|
+
detectNextJsProject,
|
|
1469
|
+
runScan
|
|
1470
|
+
};
|
|
1471
|
+
//# sourceMappingURL=chunk-QJKUBMF6.js.map
|