extraktor 1.0.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/LICENSE +21 -0
- package/README.md +174 -0
- package/dist/chunk-5IH5TLAQ.js +91 -0
- package/dist/chunk-PHMSK7VD.js +6411 -0
- package/dist/chunk-VLLFGYUN.js +2773 -0
- package/dist/cli/index.d.ts +1 -0
- package/dist/cli/index.js +3261 -0
- package/dist/design-md-generator-YMQOE2IW.js +502 -0
- package/dist/index.d.ts +2774 -0
- package/dist/index.js +58 -0
- package/dist/server-DR7RCM5S.js +328 -0
- package/dist/style-applier-BMHP6V57.js +1032 -0
- package/dist/theme-package-generator-E55BBBZN.js +412 -0
- package/package.json +99 -0
- package/skills/analyze-design.md +20 -0
- package/skills/apply-style.md +24 -0
- package/skills/clone-site.md +29 -0
- package/skills/design-md.md +29 -0
- package/skills/devtools-extract.md +30 -0
- package/skills/extract-tokens.md +21 -0
- package/skills/genome.md +31 -0
- package/skills/regen.md +32 -0
|
@@ -0,0 +1,2773 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Orchestrator,
|
|
3
|
+
getDefaultConfig
|
|
4
|
+
} from "./chunk-PHMSK7VD.js";
|
|
5
|
+
import {
|
|
6
|
+
createLogger
|
|
7
|
+
} from "./chunk-5IH5TLAQ.js";
|
|
8
|
+
|
|
9
|
+
// src/cloner/site-cloner.ts
|
|
10
|
+
import fs from "fs-extra";
|
|
11
|
+
import path from "path";
|
|
12
|
+
import crypto from "crypto";
|
|
13
|
+
|
|
14
|
+
// src/cloner/content-extractor.ts
|
|
15
|
+
var ContentExtractor = class {
|
|
16
|
+
/**
|
|
17
|
+
* Extract all editable content from a page
|
|
18
|
+
*/
|
|
19
|
+
async extract(page, sourceUrl) {
|
|
20
|
+
const extracted = await page.evaluate(() => {
|
|
21
|
+
const items = [];
|
|
22
|
+
const images = [];
|
|
23
|
+
const links = [];
|
|
24
|
+
let itemIndex = 0;
|
|
25
|
+
function getSelector(el) {
|
|
26
|
+
if (el.id) return `#${el.id}`;
|
|
27
|
+
const classes = Array.from(el.classList).filter((c) => c.startsWith("framer-") || c.length < 20).slice(0, 2).join(".");
|
|
28
|
+
if (classes) {
|
|
29
|
+
const selector = `${el.tagName.toLowerCase()}.${classes}`;
|
|
30
|
+
if (document.querySelectorAll(selector).length === 1) {
|
|
31
|
+
return selector;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
const parent = el.parentElement;
|
|
35
|
+
if (parent) {
|
|
36
|
+
const index = Array.from(parent.children).indexOf(el) + 1;
|
|
37
|
+
const parentSelector = getSelector(parent);
|
|
38
|
+
return `${parentSelector} > ${el.tagName.toLowerCase()}:nth-child(${index})`;
|
|
39
|
+
}
|
|
40
|
+
return el.tagName.toLowerCase();
|
|
41
|
+
}
|
|
42
|
+
function generateId(type, text) {
|
|
43
|
+
const slug = text.toLowerCase().replace(/[^a-z0-9]+/g, "-").slice(0, 30).replace(/-+$/, "");
|
|
44
|
+
return `${type}-${itemIndex++}-${slug || "item"}`;
|
|
45
|
+
}
|
|
46
|
+
document.querySelectorAll("h1, h2, h3, h4, h5, h6").forEach((el) => {
|
|
47
|
+
const text = el.textContent?.trim();
|
|
48
|
+
if (text && text.length > 1) {
|
|
49
|
+
items.push({
|
|
50
|
+
id: generateId("heading", text),
|
|
51
|
+
type: "text",
|
|
52
|
+
selector: getSelector(el),
|
|
53
|
+
value: text,
|
|
54
|
+
tag: el.tagName.toLowerCase()
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
document.querySelectorAll("p").forEach((el) => {
|
|
59
|
+
const text = el.textContent?.trim();
|
|
60
|
+
if (text && text.length > 10) {
|
|
61
|
+
items.push({
|
|
62
|
+
id: generateId("text", text),
|
|
63
|
+
type: "text",
|
|
64
|
+
selector: getSelector(el),
|
|
65
|
+
value: text,
|
|
66
|
+
tag: "p"
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
document.querySelectorAll('button, [role="button"], a[class*="button"], a[class*="btn"]').forEach((el) => {
|
|
71
|
+
const text = el.textContent?.trim();
|
|
72
|
+
if (text && text.length > 1 && text.length < 50) {
|
|
73
|
+
items.push({
|
|
74
|
+
id: generateId("button", text),
|
|
75
|
+
type: "button",
|
|
76
|
+
selector: getSelector(el),
|
|
77
|
+
value: text,
|
|
78
|
+
href: el.href || void 0
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
document.querySelectorAll("img").forEach((el) => {
|
|
83
|
+
const img = el;
|
|
84
|
+
if (img.src && !img.src.startsWith("data:") && img.width > 50) {
|
|
85
|
+
images.push({
|
|
86
|
+
id: generateId("image", img.alt || "img"),
|
|
87
|
+
type: "image",
|
|
88
|
+
selector: getSelector(el),
|
|
89
|
+
value: img.src,
|
|
90
|
+
alt: img.alt || ""
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
document.querySelectorAll("a[href]").forEach((el) => {
|
|
95
|
+
const link = el;
|
|
96
|
+
const text = link.textContent?.trim();
|
|
97
|
+
if (text && text.length > 1 && link.href && !link.href.startsWith("javascript:")) {
|
|
98
|
+
links.push({
|
|
99
|
+
id: generateId("link", text),
|
|
100
|
+
type: "link",
|
|
101
|
+
selector: getSelector(el),
|
|
102
|
+
value: text,
|
|
103
|
+
href: link.href
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
return { items, images, links };
|
|
108
|
+
});
|
|
109
|
+
const sections = this.organizeSections(extracted.items);
|
|
110
|
+
return {
|
|
111
|
+
meta: {
|
|
112
|
+
sourceUrl,
|
|
113
|
+
extractedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
114
|
+
version: "1.0.0"
|
|
115
|
+
},
|
|
116
|
+
sections,
|
|
117
|
+
images: extracted.images,
|
|
118
|
+
links: extracted.links
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Organize items into logical sections
|
|
123
|
+
*/
|
|
124
|
+
organizeSections(items) {
|
|
125
|
+
const sections = {};
|
|
126
|
+
let currentSection = "hero";
|
|
127
|
+
let sectionIndex = 0;
|
|
128
|
+
items.forEach((item) => {
|
|
129
|
+
if (item.tag === "h1" || item.tag === "h2") {
|
|
130
|
+
const sectionName = item.value.toLowerCase().replace(/[^a-z0-9]+/g, "-").slice(0, 30) || `section-${sectionIndex}`;
|
|
131
|
+
currentSection = sectionName;
|
|
132
|
+
sectionIndex++;
|
|
133
|
+
}
|
|
134
|
+
if (!sections[currentSection]) {
|
|
135
|
+
sections[currentSection] = {
|
|
136
|
+
name: currentSection.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()),
|
|
137
|
+
items: []
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
sections[currentSection].items.push(item);
|
|
141
|
+
});
|
|
142
|
+
return sections;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Generate a content loader script that applies content.json to the page
|
|
146
|
+
*/
|
|
147
|
+
generateLoaderScript() {
|
|
148
|
+
return `/**
|
|
149
|
+
* Content Loader - Applies content.json to the page
|
|
150
|
+
* Edit content.json to change text and images
|
|
151
|
+
*/
|
|
152
|
+
|
|
153
|
+
(function() {
|
|
154
|
+
'use strict';
|
|
155
|
+
|
|
156
|
+
async function loadContent() {
|
|
157
|
+
try {
|
|
158
|
+
const response = await fetch('/content.json');
|
|
159
|
+
const content = await response.json();
|
|
160
|
+
applyContent(content);
|
|
161
|
+
} catch (e) {
|
|
162
|
+
console.log('No content.json found, using default content');
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function applyContent(content) {
|
|
167
|
+
// Apply text content from sections
|
|
168
|
+
Object.values(content.sections || {}).forEach(section => {
|
|
169
|
+
(section.items || []).forEach(item => {
|
|
170
|
+
if (item.type === 'text' || item.type === 'button') {
|
|
171
|
+
const el = document.querySelector(item.selector);
|
|
172
|
+
if (el) {
|
|
173
|
+
el.textContent = item.value;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// Apply images
|
|
180
|
+
(content.images || []).forEach(item => {
|
|
181
|
+
const el = document.querySelector(item.selector);
|
|
182
|
+
if (el && el.tagName === 'IMG') {
|
|
183
|
+
el.src = item.value;
|
|
184
|
+
if (item.alt) el.alt = item.alt;
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// Apply links
|
|
189
|
+
(content.links || []).forEach(item => {
|
|
190
|
+
const el = document.querySelector(item.selector);
|
|
191
|
+
if (el) {
|
|
192
|
+
if (item.value) el.textContent = item.value;
|
|
193
|
+
if (item.href) el.href = item.href;
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
console.log('Content loaded from content.json');
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Load on DOM ready
|
|
201
|
+
if (document.readyState === 'loading') {
|
|
202
|
+
document.addEventListener('DOMContentLoaded', loadContent);
|
|
203
|
+
} else {
|
|
204
|
+
loadContent();
|
|
205
|
+
}
|
|
206
|
+
})();
|
|
207
|
+
`;
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
// src/cloner/site-cloner.ts
|
|
212
|
+
var SiteCloner = class {
|
|
213
|
+
assetMap = /* @__PURE__ */ new Map();
|
|
214
|
+
baseUrl = "";
|
|
215
|
+
contentExtractor = new ContentExtractor();
|
|
216
|
+
async clone(page, options) {
|
|
217
|
+
this.baseUrl = new URL(options.url).origin;
|
|
218
|
+
const projectDir = path.join(options.outputDir, options.projectName);
|
|
219
|
+
const platform = await this.detectPlatform(page);
|
|
220
|
+
console.log(`Detected platform: ${platform}`);
|
|
221
|
+
const animations = await this.detectAnimations(page);
|
|
222
|
+
console.log(`Detected animations:`, animations);
|
|
223
|
+
await this.createProjectStructure(projectDir);
|
|
224
|
+
const extracted = await this.extractPage(page);
|
|
225
|
+
if (options.downloadAssets) {
|
|
226
|
+
await this.downloadAssets(extracted.assets, projectDir);
|
|
227
|
+
}
|
|
228
|
+
await this.downloadStylesheets(extracted.stylesheetUrls, projectDir);
|
|
229
|
+
const processedHtml = this.processHtml(extracted.html);
|
|
230
|
+
let contentExtracted = false;
|
|
231
|
+
if (options.extractContent) {
|
|
232
|
+
const contentJson = await this.contentExtractor.extract(page, options.url);
|
|
233
|
+
await fs.writeJson(path.join(projectDir, "public/content.json"), contentJson, { spaces: 2 });
|
|
234
|
+
const loaderScript = this.contentExtractor.generateLoaderScript();
|
|
235
|
+
await fs.writeFile(path.join(projectDir, "public/content-loader.js"), loaderScript);
|
|
236
|
+
contentExtracted = true;
|
|
237
|
+
console.log("Content extracted to content.json");
|
|
238
|
+
}
|
|
239
|
+
await this.generateNextjsProject(projectDir, processedHtml, extracted.styles, extracted.keyframes, options, contentExtracted);
|
|
240
|
+
await this.generateAnimationScript(projectDir, platform, animations);
|
|
241
|
+
let pagesCloned = 1;
|
|
242
|
+
if (options.crawlPages) {
|
|
243
|
+
const discoveredPages = await this.discoverPages(page);
|
|
244
|
+
console.log(`Discovered ${discoveredPages.length} additional pages to clone`);
|
|
245
|
+
for (const pagePath of discoveredPages) {
|
|
246
|
+
try {
|
|
247
|
+
const pageUrl = `${this.baseUrl}${pagePath}`;
|
|
248
|
+
console.log(`Cloning page: ${pageUrl}`);
|
|
249
|
+
await page.goto(pageUrl, { waitUntil: "networkidle" });
|
|
250
|
+
await page.waitForTimeout(1e3);
|
|
251
|
+
const pageExtracted = await this.extractPage(page);
|
|
252
|
+
if (options.downloadAssets) {
|
|
253
|
+
await this.downloadAssets(pageExtracted.assets, projectDir);
|
|
254
|
+
}
|
|
255
|
+
const pageHtml = this.processHtml(pageExtracted.html);
|
|
256
|
+
const safeFilename = pagePath.replace(/^\//, "").replace(/\//g, "-") || "page";
|
|
257
|
+
await fs.writeFile(
|
|
258
|
+
path.join(projectDir, `public/${safeFilename}.html`),
|
|
259
|
+
pageHtml
|
|
260
|
+
);
|
|
261
|
+
if (options.extractContent) {
|
|
262
|
+
const pageContent = await this.contentExtractor.extract(page, pageUrl);
|
|
263
|
+
await fs.writeJson(
|
|
264
|
+
path.join(projectDir, `public/content-${safeFilename}.json`),
|
|
265
|
+
pageContent,
|
|
266
|
+
{ spaces: 2 }
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
pagesCloned++;
|
|
270
|
+
} catch (err) {
|
|
271
|
+
console.log(`Failed to clone page ${pagePath}: ${err}`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return {
|
|
276
|
+
projectDir,
|
|
277
|
+
assets: {
|
|
278
|
+
images: extracted.assets.images.length,
|
|
279
|
+
fonts: extracted.assets.fonts.length,
|
|
280
|
+
svgs: extracted.assets.svgs.length,
|
|
281
|
+
videos: extracted.assets.videos.length
|
|
282
|
+
},
|
|
283
|
+
components: 1,
|
|
284
|
+
platform,
|
|
285
|
+
contentExtracted,
|
|
286
|
+
pagesCloned
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Detect the platform the site is built on
|
|
291
|
+
*/
|
|
292
|
+
async detectPlatform(page) {
|
|
293
|
+
return page.evaluate(() => {
|
|
294
|
+
const html = document.documentElement.outerHTML.toLowerCase();
|
|
295
|
+
const scripts = Array.from(document.scripts).map((s) => s.src).join(" ").toLowerCase();
|
|
296
|
+
if (document.querySelector("[data-framer-component-type]") || html.includes("framer.com") || html.includes("framerusercontent.com") || document.querySelector('[class*="framer-"]')) {
|
|
297
|
+
return "framer";
|
|
298
|
+
}
|
|
299
|
+
if (html.includes("webflow.com") || document.querySelector("[data-wf-site]") || document.querySelector(".w-webflow-badge")) {
|
|
300
|
+
return "webflow";
|
|
301
|
+
}
|
|
302
|
+
if (html.includes("wp-content") || html.includes("wp-includes") || document.querySelector('[class*="wp-"]')) {
|
|
303
|
+
return "wordpress";
|
|
304
|
+
}
|
|
305
|
+
if (html.includes("shopify.com") || html.includes("cdn.shopify.com") || document.querySelector("[data-shopify]")) {
|
|
306
|
+
return "shopify";
|
|
307
|
+
}
|
|
308
|
+
if (html.includes("squarespace.com") || scripts.includes("squarespace")) {
|
|
309
|
+
return "squarespace";
|
|
310
|
+
}
|
|
311
|
+
if (html.includes("wix.com") || scripts.includes("wix")) {
|
|
312
|
+
return "wix";
|
|
313
|
+
}
|
|
314
|
+
return "custom";
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* Detect animation libraries used on the page
|
|
319
|
+
*/
|
|
320
|
+
async detectAnimations(page) {
|
|
321
|
+
return page.evaluate(() => {
|
|
322
|
+
const scripts = Array.from(document.scripts).map((s) => s.src + " " + (s.textContent || "")).join(" ").toLowerCase();
|
|
323
|
+
const html = document.documentElement.outerHTML.toLowerCase();
|
|
324
|
+
return {
|
|
325
|
+
framer: !!document.querySelector("[data-framer-appear-id]") || html.includes("framer-motion"),
|
|
326
|
+
gsap: scripts.includes("gsap") || scripts.includes("greensock") || typeof window.gsap !== "undefined",
|
|
327
|
+
lottie: scripts.includes("lottie") || !!document.querySelector("lottie-player") || !!document.querySelector("[data-animation-path]"),
|
|
328
|
+
scrollTrigger: scripts.includes("scrolltrigger") || !!document.querySelector("[data-scroll]"),
|
|
329
|
+
aos: scripts.includes("aos.js") || !!document.querySelector("[data-aos]")
|
|
330
|
+
};
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* Discover all internal pages for multi-page crawling
|
|
335
|
+
*/
|
|
336
|
+
async discoverPages(page) {
|
|
337
|
+
const baseUrl = this.baseUrl;
|
|
338
|
+
return page.evaluate((base) => {
|
|
339
|
+
const links = Array.from(document.querySelectorAll("a[href]"));
|
|
340
|
+
const pages = [];
|
|
341
|
+
links.forEach((link) => {
|
|
342
|
+
const href = link.href;
|
|
343
|
+
try {
|
|
344
|
+
const url = new URL(href);
|
|
345
|
+
if (url.origin === base && !url.hash && !href.includes("mailto:") && !href.includes("tel:") && !pages.includes(url.pathname)) {
|
|
346
|
+
pages.push(url.pathname);
|
|
347
|
+
}
|
|
348
|
+
} catch {
|
|
349
|
+
}
|
|
350
|
+
});
|
|
351
|
+
return pages.filter((p) => p !== "/" && p !== "");
|
|
352
|
+
}, baseUrl);
|
|
353
|
+
}
|
|
354
|
+
async createProjectStructure(projectDir) {
|
|
355
|
+
await fs.ensureDir(path.join(projectDir, "src/app"));
|
|
356
|
+
await fs.ensureDir(path.join(projectDir, "src/styles"));
|
|
357
|
+
await fs.ensureDir(path.join(projectDir, "public/images"));
|
|
358
|
+
await fs.ensureDir(path.join(projectDir, "public/fonts"));
|
|
359
|
+
await fs.ensureDir(path.join(projectDir, "public/videos"));
|
|
360
|
+
}
|
|
361
|
+
async extractPage(page) {
|
|
362
|
+
return page.evaluate(() => {
|
|
363
|
+
const images = [];
|
|
364
|
+
const fonts = [];
|
|
365
|
+
const svgs = [];
|
|
366
|
+
const videos = [];
|
|
367
|
+
const stylesheetUrls = [];
|
|
368
|
+
let keyframes = "";
|
|
369
|
+
let inlineStyles = "";
|
|
370
|
+
document.querySelectorAll("img").forEach((img) => {
|
|
371
|
+
if (img.src && !img.src.startsWith("data:")) {
|
|
372
|
+
images.push(img.src);
|
|
373
|
+
}
|
|
374
|
+
});
|
|
375
|
+
document.querySelectorAll("video").forEach((video) => {
|
|
376
|
+
if (video.src && !video.src.startsWith("data:")) {
|
|
377
|
+
videos.push(video.src);
|
|
378
|
+
}
|
|
379
|
+
video.querySelectorAll("source").forEach((source) => {
|
|
380
|
+
if (source.src && !source.src.startsWith("data:")) {
|
|
381
|
+
videos.push(source.src);
|
|
382
|
+
}
|
|
383
|
+
});
|
|
384
|
+
});
|
|
385
|
+
document.querySelectorAll("*").forEach((el) => {
|
|
386
|
+
const style = getComputedStyle(el);
|
|
387
|
+
const bg = style.backgroundImage;
|
|
388
|
+
if (bg && bg !== "none") {
|
|
389
|
+
const matches = bg.matchAll(/url\(["']?([^"')]+)["']?\)/g);
|
|
390
|
+
for (const match of matches) {
|
|
391
|
+
if (match[1] && !match[1].startsWith("data:")) {
|
|
392
|
+
images.push(match[1]);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
});
|
|
397
|
+
for (const sheet of document.styleSheets) {
|
|
398
|
+
try {
|
|
399
|
+
if (sheet.href) {
|
|
400
|
+
stylesheetUrls.push(sheet.href);
|
|
401
|
+
}
|
|
402
|
+
for (const rule of sheet.cssRules) {
|
|
403
|
+
if (rule instanceof CSSKeyframesRule) {
|
|
404
|
+
keyframes += rule.cssText + "\n";
|
|
405
|
+
}
|
|
406
|
+
if (rule instanceof CSSFontFaceRule) {
|
|
407
|
+
const src = rule.style.getPropertyValue("src");
|
|
408
|
+
const matches = src.matchAll(/url\(["']?([^"')]+)["']?\)/g);
|
|
409
|
+
for (const match of matches) {
|
|
410
|
+
if (match[1] && !match[1].startsWith("data:")) {
|
|
411
|
+
fonts.push(match[1]);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
} catch (e) {
|
|
417
|
+
if (sheet.href) {
|
|
418
|
+
stylesheetUrls.push(sheet.href);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
document.querySelectorAll("style").forEach((style) => {
|
|
423
|
+
inlineStyles += style.textContent + "\n";
|
|
424
|
+
});
|
|
425
|
+
const html = document.documentElement.outerHTML;
|
|
426
|
+
return {
|
|
427
|
+
html,
|
|
428
|
+
styles: inlineStyles,
|
|
429
|
+
keyframes,
|
|
430
|
+
stylesheetUrls: [...new Set(stylesheetUrls)],
|
|
431
|
+
assets: {
|
|
432
|
+
images: [...new Set(images)],
|
|
433
|
+
fonts: [...new Set(fonts)],
|
|
434
|
+
svgs: [],
|
|
435
|
+
videos: [...new Set(videos)]
|
|
436
|
+
}
|
|
437
|
+
};
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
async downloadStylesheets(urls, projectDir) {
|
|
441
|
+
let allCss = "";
|
|
442
|
+
for (const url of urls) {
|
|
443
|
+
try {
|
|
444
|
+
const fullUrl = url.startsWith("http") ? url : `${this.baseUrl}${url}`;
|
|
445
|
+
const response = await fetch(fullUrl);
|
|
446
|
+
if (response.ok) {
|
|
447
|
+
let css = await response.text();
|
|
448
|
+
css = css.replace(/url\(["']?([^"')]+)["']?\)/g, (match, assetUrl) => {
|
|
449
|
+
if (assetUrl.startsWith("data:")) return match;
|
|
450
|
+
let absoluteUrl = assetUrl;
|
|
451
|
+
if (!assetUrl.startsWith("http")) {
|
|
452
|
+
try {
|
|
453
|
+
absoluteUrl = new URL(assetUrl, fullUrl).href;
|
|
454
|
+
} catch {
|
|
455
|
+
return match;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
const localPath = this.assetMap.get(absoluteUrl);
|
|
459
|
+
if (localPath) {
|
|
460
|
+
return `url("${localPath}")`;
|
|
461
|
+
}
|
|
462
|
+
return match;
|
|
463
|
+
});
|
|
464
|
+
allCss += `/* From: ${url} */
|
|
465
|
+
${css}
|
|
466
|
+
|
|
467
|
+
`;
|
|
468
|
+
}
|
|
469
|
+
} catch (e) {
|
|
470
|
+
console.log(`Failed to download stylesheet: ${url}`);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
if (allCss) {
|
|
474
|
+
await fs.writeFile(path.join(projectDir, "src/styles/external.css"), allCss);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
async downloadAssets(assets, projectDir) {
|
|
478
|
+
for (const url of assets.images) {
|
|
479
|
+
try {
|
|
480
|
+
const fullUrl = url.startsWith("http") ? url : `${this.baseUrl}${url}`;
|
|
481
|
+
const response = await fetch(fullUrl);
|
|
482
|
+
if (response.ok) {
|
|
483
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
484
|
+
const ext = this.getExtension(url) || "png";
|
|
485
|
+
const hash = crypto.createHash("md5").update(url).digest("hex").slice(0, 8);
|
|
486
|
+
const filename = `img-${hash}.${ext}`;
|
|
487
|
+
const localPath = path.join(projectDir, "public/images", filename);
|
|
488
|
+
await fs.writeFile(localPath, buffer);
|
|
489
|
+
this.assetMap.set(url, `/images/${filename}`);
|
|
490
|
+
this.assetMap.set(fullUrl, `/images/${filename}`);
|
|
491
|
+
}
|
|
492
|
+
} catch (e) {
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
for (const url of assets.fonts) {
|
|
496
|
+
try {
|
|
497
|
+
const fullUrl = url.startsWith("http") ? url : `${this.baseUrl}${url}`;
|
|
498
|
+
const response = await fetch(fullUrl);
|
|
499
|
+
if (response.ok) {
|
|
500
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
501
|
+
const ext = this.getExtension(url) || "woff2";
|
|
502
|
+
const hash = crypto.createHash("md5").update(url).digest("hex").slice(0, 8);
|
|
503
|
+
const filename = `font-${hash}.${ext}`;
|
|
504
|
+
const localPath = path.join(projectDir, "public/fonts", filename);
|
|
505
|
+
await fs.writeFile(localPath, buffer);
|
|
506
|
+
this.assetMap.set(url, `/fonts/${filename}`);
|
|
507
|
+
this.assetMap.set(fullUrl, `/fonts/${filename}`);
|
|
508
|
+
}
|
|
509
|
+
} catch (e) {
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
for (const url of assets.videos) {
|
|
513
|
+
try {
|
|
514
|
+
const fullUrl = url.startsWith("http") ? url : `${this.baseUrl}${url}`;
|
|
515
|
+
const response = await fetch(fullUrl);
|
|
516
|
+
if (response.ok) {
|
|
517
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
518
|
+
const ext = this.getExtension(url) || "mp4";
|
|
519
|
+
const hash = crypto.createHash("md5").update(url).digest("hex").slice(0, 8);
|
|
520
|
+
const filename = `video-${hash}.${ext}`;
|
|
521
|
+
const localPath = path.join(projectDir, "public/videos", filename);
|
|
522
|
+
await fs.writeFile(localPath, buffer);
|
|
523
|
+
this.assetMap.set(url, `/videos/${filename}`);
|
|
524
|
+
this.assetMap.set(fullUrl, `/videos/${filename}`);
|
|
525
|
+
}
|
|
526
|
+
} catch (e) {
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
processHtml(html) {
|
|
531
|
+
let processed = html.replace(/<!--[\s\S]*?-->/g, "").replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, "").replace(/<noscript\b[^>]*>[\s\S]*?<\/noscript>/gi, "").replace(/<link\b(?![^>]*rel=["']stylesheet["'])[^>]*\/?>/gi, "").replace(/<meta\b[^>]*\/?>/gi, "").replace(/<iframe\b[^>]*>[\s\S]*?<\/iframe>/gi, "").replace(/data-style="/g, 'style="').replace(/src="([^"]*)"/g, (match, url) => {
|
|
532
|
+
if (url.startsWith("data:")) return match;
|
|
533
|
+
const localPath = this.assetMap.get(url);
|
|
534
|
+
return localPath ? `src="${localPath}"` : match;
|
|
535
|
+
}).replace(/srcset="([^"]*)"/g, (match, srcset) => {
|
|
536
|
+
const newSrcset = srcset.split(",").map((src) => {
|
|
537
|
+
const parts = src.trim().split(/\s+/);
|
|
538
|
+
const url = parts[0];
|
|
539
|
+
if (url.startsWith("data:")) return src;
|
|
540
|
+
const localPath = this.assetMap.get(url);
|
|
541
|
+
if (localPath) parts[0] = localPath;
|
|
542
|
+
return parts.join(" ");
|
|
543
|
+
}).join(", ");
|
|
544
|
+
return `srcset="${newSrcset}"`;
|
|
545
|
+
}).replace(/url\(["']?([^"')]+)["']?\)/g, (match, url) => {
|
|
546
|
+
if (url.startsWith("data:")) return match;
|
|
547
|
+
const localPath = this.assetMap.get(url);
|
|
548
|
+
return localPath ? `url("${localPath}")` : match;
|
|
549
|
+
}).replace(/\n\s*\n\s*\n/g, "\n\n").trim();
|
|
550
|
+
processed = processed.replace("</body>", '<script src="/animations.js"></script></body>');
|
|
551
|
+
return processed;
|
|
552
|
+
}
|
|
553
|
+
getExtension(url) {
|
|
554
|
+
const match = url.match(/\.([a-z0-9]+)(?:\?|$)/i);
|
|
555
|
+
return match ? match[1] : null;
|
|
556
|
+
}
|
|
557
|
+
/**
|
|
558
|
+
* Generate animation/interaction script for the cloned site
|
|
559
|
+
*/
|
|
560
|
+
async generateAnimationScript(projectDir, platform, animations) {
|
|
561
|
+
const initCalls = [
|
|
562
|
+
"initAppearAnimations();",
|
|
563
|
+
"initKeyboardNavigation();",
|
|
564
|
+
"initHoverEffects();",
|
|
565
|
+
"initSmoothScroll();",
|
|
566
|
+
"initVideoCarousel();",
|
|
567
|
+
"initTabSwitching();",
|
|
568
|
+
"initFaqAccordion();"
|
|
569
|
+
];
|
|
570
|
+
if (animations.aos || platform === "webflow" || platform === "squarespace") {
|
|
571
|
+
initCalls.push("initAOSAnimations();");
|
|
572
|
+
}
|
|
573
|
+
if (animations.scrollTrigger || platform === "webflow") {
|
|
574
|
+
initCalls.push("initScrollParallax();");
|
|
575
|
+
}
|
|
576
|
+
if (platform === "framer") {
|
|
577
|
+
initCalls.push("initFramerSpecific();");
|
|
578
|
+
} else if (platform === "webflow") {
|
|
579
|
+
initCalls.push("initWebflowSpecific();");
|
|
580
|
+
}
|
|
581
|
+
const script = `/**
|
|
582
|
+
* Extraktor Animation & Interaction Script
|
|
583
|
+
* Auto-generated for platform: ${platform}
|
|
584
|
+
* Detected: ${Object.entries(animations).filter(([_, v]) => v).map(([k]) => k).join(", ") || "native"}
|
|
585
|
+
*/
|
|
586
|
+
|
|
587
|
+
(function() {
|
|
588
|
+
'use strict';
|
|
589
|
+
|
|
590
|
+
if (document.readyState === 'loading') {
|
|
591
|
+
document.addEventListener('DOMContentLoaded', init);
|
|
592
|
+
} else {
|
|
593
|
+
init();
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function init() {
|
|
597
|
+
${initCalls.join("\n ")}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// AOS-style scroll animations (works for all platforms)
|
|
601
|
+
function initAOSAnimations() {
|
|
602
|
+
const aosElements = document.querySelectorAll('[data-aos], [data-scroll], [class*="animate-"]');
|
|
603
|
+
|
|
604
|
+
aosElements.forEach(el => {
|
|
605
|
+
el.style.opacity = '0';
|
|
606
|
+
el.style.transform = 'translateY(30px)';
|
|
607
|
+
el.style.transition = 'opacity 0.6s ease, transform 0.6s ease';
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
const observer = new IntersectionObserver((entries) => {
|
|
611
|
+
entries.forEach(entry => {
|
|
612
|
+
if (entry.isIntersecting) {
|
|
613
|
+
entry.target.style.opacity = '1';
|
|
614
|
+
entry.target.style.transform = 'translateY(0)';
|
|
615
|
+
observer.unobserve(entry.target);
|
|
616
|
+
}
|
|
617
|
+
});
|
|
618
|
+
}, { threshold: 0.15 });
|
|
619
|
+
|
|
620
|
+
aosElements.forEach(el => observer.observe(el));
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// Scroll-triggered parallax effects
|
|
624
|
+
function initScrollParallax() {
|
|
625
|
+
const parallaxElements = document.querySelectorAll('[data-parallax], [class*="parallax"]');
|
|
626
|
+
|
|
627
|
+
window.addEventListener('scroll', () => {
|
|
628
|
+
const scrollY = window.scrollY;
|
|
629
|
+
|
|
630
|
+
parallaxElements.forEach(el => {
|
|
631
|
+
const speed = parseFloat(el.dataset?.parallax || '0.5');
|
|
632
|
+
const yPos = -(scrollY * speed);
|
|
633
|
+
el.style.transform = \`translateY(\${yPos}px)\`;
|
|
634
|
+
});
|
|
635
|
+
}, { passive: true });
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// Framer-specific optimizations
|
|
639
|
+
function initFramerSpecific() {
|
|
640
|
+
// Fix Framer's data-framer-generated-page-id scroll behavior
|
|
641
|
+
const framerPages = document.querySelectorAll('[data-framer-generated-page-id]');
|
|
642
|
+
framerPages.forEach(page => {
|
|
643
|
+
page.style.overflowX = 'hidden';
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
// Enable smooth transitions on framer variants
|
|
647
|
+
const variants = document.querySelectorAll('[data-framer-component-variants]');
|
|
648
|
+
variants.forEach(v => {
|
|
649
|
+
v.style.transition = 'all 0.3s ease';
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// Webflow-specific optimizations
|
|
654
|
+
function initWebflowSpecific() {
|
|
655
|
+
// Fix Webflow IX2 interactions
|
|
656
|
+
const ixElements = document.querySelectorAll('[data-w-id]');
|
|
657
|
+
ixElements.forEach(el => {
|
|
658
|
+
el.style.transition = 'transform 0.4s ease, opacity 0.4s ease';
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
// Handle Webflow slider
|
|
662
|
+
const sliders = document.querySelectorAll('.w-slider');
|
|
663
|
+
sliders.forEach(slider => {
|
|
664
|
+
slider.style.overflow = 'hidden';
|
|
665
|
+
});
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// Entrance animations using Intersection Observer
|
|
669
|
+
function initAppearAnimations() {
|
|
670
|
+
const appearElements = document.querySelectorAll('[data-framer-appear-id]');
|
|
671
|
+
if (!appearElements.length) return;
|
|
672
|
+
|
|
673
|
+
appearElements.forEach(el => {
|
|
674
|
+
el.style.opacity = '0';
|
|
675
|
+
el.style.transform = 'translateY(20px)';
|
|
676
|
+
el.style.transition = 'opacity 0.6s ease-out, transform 0.6s ease-out';
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
const observer = new IntersectionObserver((entries) => {
|
|
680
|
+
entries.forEach((entry, index) => {
|
|
681
|
+
if (entry.isIntersecting) {
|
|
682
|
+
setTimeout(() => {
|
|
683
|
+
entry.target.style.opacity = '1';
|
|
684
|
+
entry.target.style.transform = 'translateY(0)';
|
|
685
|
+
}, index * 100);
|
|
686
|
+
observer.unobserve(entry.target);
|
|
687
|
+
}
|
|
688
|
+
});
|
|
689
|
+
}, { threshold: 0.1, rootMargin: '0px 0px -50px 0px' });
|
|
690
|
+
|
|
691
|
+
appearElements.forEach(el => observer.observe(el));
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// Video carousel - play on hover, drag to scroll
|
|
695
|
+
function initVideoCarousel() {
|
|
696
|
+
const videos = document.querySelectorAll('video');
|
|
697
|
+
|
|
698
|
+
videos.forEach(video => {
|
|
699
|
+
video.preload = 'auto';
|
|
700
|
+
|
|
701
|
+
const container = video.closest('li') || video.closest('[class*="framer-"]');
|
|
702
|
+
if (container) {
|
|
703
|
+
container.addEventListener('mouseenter', () => {
|
|
704
|
+
video.play().catch(() => {});
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
container.addEventListener('mouseleave', () => {
|
|
708
|
+
video.pause();
|
|
709
|
+
video.currentTime = 0;
|
|
710
|
+
});
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
const videoObserver = new IntersectionObserver((entries) => {
|
|
714
|
+
entries.forEach(entry => {
|
|
715
|
+
if (entry.isIntersecting) {
|
|
716
|
+
video.play().catch(() => {});
|
|
717
|
+
} else {
|
|
718
|
+
video.pause();
|
|
719
|
+
}
|
|
720
|
+
});
|
|
721
|
+
}, { threshold: 0.5 });
|
|
722
|
+
|
|
723
|
+
videoObserver.observe(video);
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
// Make carousels draggable
|
|
727
|
+
const carousels = document.querySelectorAll('ul[style*="translateX"]');
|
|
728
|
+
carousels.forEach(carousel => {
|
|
729
|
+
let isDown = false;
|
|
730
|
+
let startX;
|
|
731
|
+
let scrollLeft;
|
|
732
|
+
const parent = carousel.parentElement;
|
|
733
|
+
|
|
734
|
+
if (!parent) return;
|
|
735
|
+
|
|
736
|
+
parent.style.cursor = 'grab';
|
|
737
|
+
parent.style.overflow = 'auto';
|
|
738
|
+
parent.style.scrollBehavior = 'smooth';
|
|
739
|
+
|
|
740
|
+
parent.addEventListener('mousedown', (e) => {
|
|
741
|
+
isDown = true;
|
|
742
|
+
parent.style.cursor = 'grabbing';
|
|
743
|
+
startX = e.pageX - parent.offsetLeft;
|
|
744
|
+
scrollLeft = parent.scrollLeft;
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
parent.addEventListener('mouseleave', () => {
|
|
748
|
+
isDown = false;
|
|
749
|
+
parent.style.cursor = 'grab';
|
|
750
|
+
});
|
|
751
|
+
|
|
752
|
+
parent.addEventListener('mouseup', () => {
|
|
753
|
+
isDown = false;
|
|
754
|
+
parent.style.cursor = 'grab';
|
|
755
|
+
});
|
|
756
|
+
|
|
757
|
+
parent.addEventListener('mousemove', (e) => {
|
|
758
|
+
if (!isDown) return;
|
|
759
|
+
e.preventDefault();
|
|
760
|
+
const x = e.pageX - parent.offsetLeft;
|
|
761
|
+
const walk = (x - startX) * 2;
|
|
762
|
+
parent.scrollLeft = scrollLeft - walk;
|
|
763
|
+
});
|
|
764
|
+
});
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
// Tab switching for interactive tabs
|
|
768
|
+
function initTabSwitching() {
|
|
769
|
+
const tabs = document.querySelectorAll('[name][tabindex="0"][data-highlight="true"]');
|
|
770
|
+
|
|
771
|
+
tabs.forEach(tab => {
|
|
772
|
+
tab.style.cursor = 'pointer';
|
|
773
|
+
|
|
774
|
+
tab.addEventListener('click', () => {
|
|
775
|
+
tabs.forEach(t => {
|
|
776
|
+
t.style.backgroundColor = 'rgba(255, 255, 255, 0.64)';
|
|
777
|
+
t.style.boxShadow = 'none';
|
|
778
|
+
t.style.borderWidth = '0px';
|
|
779
|
+
});
|
|
780
|
+
|
|
781
|
+
tab.style.backgroundColor = 'rgb(255, 255, 255)';
|
|
782
|
+
tab.style.boxShadow = 'rgba(0, 0, 0, 0.23) 0px 0.722625px 1.30072px -1.25px, rgba(0, 0, 0, 0.204) 0px 2.74624px 4.94323px -2.5px, rgba(0, 0, 0, 0.08) 0px 12px 21.6px -3.75px';
|
|
783
|
+
tab.style.borderWidth = '2px';
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
tab.addEventListener('mouseenter', () => {
|
|
787
|
+
if (!tab.style.borderWidth || tab.style.borderWidth === '0px') {
|
|
788
|
+
tab.style.backgroundColor = 'rgba(255, 255, 255, 0.85)';
|
|
789
|
+
}
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
tab.addEventListener('mouseleave', () => {
|
|
793
|
+
if (!tab.style.borderWidth || tab.style.borderWidth === '0px') {
|
|
794
|
+
tab.style.backgroundColor = 'rgba(255, 255, 255, 0.64)';
|
|
795
|
+
}
|
|
796
|
+
});
|
|
797
|
+
});
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
// FAQ Accordion functionality
|
|
801
|
+
function initFaqAccordion() {
|
|
802
|
+
const faqItems = document.querySelectorAll('[data-framer-name*="Rainbow"], [data-framer-name*="safe"], [data-framer-name*="Support"], [data-framer-name*="FAQ"]');
|
|
803
|
+
|
|
804
|
+
faqItems.forEach(item => {
|
|
805
|
+
const container = item.closest('[class*="container"]');
|
|
806
|
+
if (!container) return;
|
|
807
|
+
|
|
808
|
+
container.style.cursor = 'pointer';
|
|
809
|
+
|
|
810
|
+
container.addEventListener('click', () => {
|
|
811
|
+
const content = container.querySelector('[style*="height: 0"]') ||
|
|
812
|
+
container.querySelector('[style*="max-height"]');
|
|
813
|
+
if (content) {
|
|
814
|
+
const isExpanded = content.style.height !== '0px';
|
|
815
|
+
content.style.height = isExpanded ? '0px' : 'auto';
|
|
816
|
+
content.style.overflow = isExpanded ? 'hidden' : 'visible';
|
|
817
|
+
}
|
|
818
|
+
});
|
|
819
|
+
});
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
// Keyboard navigation between sections
|
|
823
|
+
function initKeyboardNavigation() {
|
|
824
|
+
const sections = Array.from(document.querySelectorAll('[class*="framer-"][class*=" framer-v-"]'));
|
|
825
|
+
let currentSection = 0;
|
|
826
|
+
|
|
827
|
+
const mainSections = sections.filter(s => {
|
|
828
|
+
const rect = s.getBoundingClientRect();
|
|
829
|
+
return rect.height > 200;
|
|
830
|
+
});
|
|
831
|
+
|
|
832
|
+
if (mainSections.length === 0) return;
|
|
833
|
+
|
|
834
|
+
document.addEventListener('keydown', (e) => {
|
|
835
|
+
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
|
|
836
|
+
|
|
837
|
+
switch(e.key) {
|
|
838
|
+
case 'ArrowDown':
|
|
839
|
+
case 'j':
|
|
840
|
+
e.preventDefault();
|
|
841
|
+
currentSection = Math.min(currentSection + 1, mainSections.length - 1);
|
|
842
|
+
scrollToSection(mainSections[currentSection]);
|
|
843
|
+
break;
|
|
844
|
+
case 'ArrowUp':
|
|
845
|
+
case 'k':
|
|
846
|
+
e.preventDefault();
|
|
847
|
+
currentSection = Math.max(currentSection - 1, 0);
|
|
848
|
+
scrollToSection(mainSections[currentSection]);
|
|
849
|
+
break;
|
|
850
|
+
case 'Home':
|
|
851
|
+
e.preventDefault();
|
|
852
|
+
currentSection = 0;
|
|
853
|
+
scrollToSection(mainSections[currentSection]);
|
|
854
|
+
break;
|
|
855
|
+
case 'End':
|
|
856
|
+
e.preventDefault();
|
|
857
|
+
currentSection = mainSections.length - 1;
|
|
858
|
+
scrollToSection(mainSections[currentSection]);
|
|
859
|
+
break;
|
|
860
|
+
default:
|
|
861
|
+
if (e.key >= '1' && e.key <= '9') {
|
|
862
|
+
const index = parseInt(e.key) - 1;
|
|
863
|
+
if (index < mainSections.length) {
|
|
864
|
+
e.preventDefault();
|
|
865
|
+
currentSection = index;
|
|
866
|
+
scrollToSection(mainSections[currentSection]);
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
});
|
|
871
|
+
|
|
872
|
+
document.addEventListener('scroll', () => {
|
|
873
|
+
const scrollPos = window.scrollY + window.innerHeight / 2;
|
|
874
|
+
mainSections.forEach((section, index) => {
|
|
875
|
+
const rect = section.getBoundingClientRect();
|
|
876
|
+
const sectionTop = window.scrollY + rect.top;
|
|
877
|
+
const sectionBottom = sectionTop + rect.height;
|
|
878
|
+
if (scrollPos >= sectionTop && scrollPos < sectionBottom) {
|
|
879
|
+
currentSection = index;
|
|
880
|
+
}
|
|
881
|
+
});
|
|
882
|
+
}, { passive: true });
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
function scrollToSection(section) {
|
|
886
|
+
if (!section) return;
|
|
887
|
+
section.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
// Hover effects for interactive elements
|
|
891
|
+
function initHoverEffects() {
|
|
892
|
+
const interactiveElements = document.querySelectorAll('a, button, [role="button"]');
|
|
893
|
+
|
|
894
|
+
interactiveElements.forEach(el => {
|
|
895
|
+
el.addEventListener('mouseenter', () => {
|
|
896
|
+
el.style.transition = 'transform 0.2s ease, box-shadow 0.2s ease';
|
|
897
|
+
el.style.transform = 'scale(1.02)';
|
|
898
|
+
});
|
|
899
|
+
|
|
900
|
+
el.addEventListener('mouseleave', () => {
|
|
901
|
+
el.style.transform = 'scale(1)';
|
|
902
|
+
});
|
|
903
|
+
});
|
|
904
|
+
|
|
905
|
+
const cards = document.querySelectorAll('[class*="framer-"][style*="border-radius"]');
|
|
906
|
+
cards.forEach(card => {
|
|
907
|
+
const originalTransform = card.style.transform || '';
|
|
908
|
+
card.addEventListener('mouseenter', () => {
|
|
909
|
+
card.style.transition = 'transform 0.3s ease, box-shadow 0.3s ease';
|
|
910
|
+
card.style.transform = originalTransform + ' translateY(-4px)';
|
|
911
|
+
card.style.boxShadow = '0 10px 40px rgba(0,0,0,0.2)';
|
|
912
|
+
});
|
|
913
|
+
|
|
914
|
+
card.addEventListener('mouseleave', () => {
|
|
915
|
+
card.style.transform = originalTransform;
|
|
916
|
+
card.style.boxShadow = '';
|
|
917
|
+
});
|
|
918
|
+
});
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
// Smooth scroll for anchor links
|
|
922
|
+
function initSmoothScroll() {
|
|
923
|
+
document.querySelectorAll('a[href^="#"]').forEach(anchor => {
|
|
924
|
+
anchor.addEventListener('click', function (e) {
|
|
925
|
+
e.preventDefault();
|
|
926
|
+
const target = document.querySelector(this.getAttribute('href'));
|
|
927
|
+
if (target) {
|
|
928
|
+
target.scrollIntoView({ behavior: 'smooth' });
|
|
929
|
+
}
|
|
930
|
+
});
|
|
931
|
+
});
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
// Rainbow click effect
|
|
935
|
+
function initRainbowClickEffect() {
|
|
936
|
+
const colors = ['#FF6B6B', '#FFE66D', '#4ECDC4', '#45B7D1', '#96CEB4', '#DDA0DD', '#FF9FF3'];
|
|
937
|
+
|
|
938
|
+
document.addEventListener('click', (e) => {
|
|
939
|
+
if (e.target.closest('a, button, input, video, [role="button"], [tabindex="0"]')) return;
|
|
940
|
+
|
|
941
|
+
const x = e.clientX;
|
|
942
|
+
const y = e.clientY;
|
|
943
|
+
|
|
944
|
+
for (let i = 0; i < 12; i++) {
|
|
945
|
+
createParticle(x, y, colors[i % colors.length], i);
|
|
946
|
+
}
|
|
947
|
+
});
|
|
948
|
+
|
|
949
|
+
function createParticle(x, y, color, index) {
|
|
950
|
+
const particle = document.createElement('div');
|
|
951
|
+
particle.style.cssText = \`
|
|
952
|
+
position: fixed;
|
|
953
|
+
left: \${x}px;
|
|
954
|
+
top: \${y}px;
|
|
955
|
+
width: 10px;
|
|
956
|
+
height: 10px;
|
|
957
|
+
background: \${color};
|
|
958
|
+
border-radius: 50%;
|
|
959
|
+
pointer-events: none;
|
|
960
|
+
z-index: 99999;
|
|
961
|
+
opacity: 1;
|
|
962
|
+
\`;
|
|
963
|
+
|
|
964
|
+
document.body.appendChild(particle);
|
|
965
|
+
|
|
966
|
+
const angle = (index / 12) * Math.PI * 2;
|
|
967
|
+
const velocity = 100 + Math.random() * 100;
|
|
968
|
+
const targetX = x + Math.cos(angle) * velocity;
|
|
969
|
+
const targetY = y + Math.sin(angle) * velocity;
|
|
970
|
+
|
|
971
|
+
particle.animate([
|
|
972
|
+
{ transform: 'scale(1) translate(0, 0)', opacity: 1 },
|
|
973
|
+
{ transform: \`scale(0.5) translate(\${targetX - x}px, \${targetY - y}px)\`, opacity: 0 }
|
|
974
|
+
], {
|
|
975
|
+
duration: 600,
|
|
976
|
+
easing: 'cubic-bezier(0, 0.55, 0.45, 1)'
|
|
977
|
+
}).onfinish = () => particle.remove();
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
})();
|
|
982
|
+
`;
|
|
983
|
+
await fs.writeFile(path.join(projectDir, "public/animations.js"), script);
|
|
984
|
+
}
|
|
985
|
+
async generateNextjsProject(projectDir, htmlContent, inlineStyles, keyframes, options, contentExtracted = false) {
|
|
986
|
+
const pkg = {
|
|
987
|
+
name: options.projectName,
|
|
988
|
+
version: "0.1.0",
|
|
989
|
+
private: true,
|
|
990
|
+
scripts: {
|
|
991
|
+
dev: "next dev",
|
|
992
|
+
build: "next build",
|
|
993
|
+
start: "next start"
|
|
994
|
+
},
|
|
995
|
+
dependencies: {
|
|
996
|
+
next: "^15.1.0",
|
|
997
|
+
react: "^19.0.0",
|
|
998
|
+
"react-dom": "^19.0.0",
|
|
999
|
+
"framer-motion": "^11.15.0"
|
|
1000
|
+
},
|
|
1001
|
+
devDependencies: {
|
|
1002
|
+
typescript: "^5.7.0",
|
|
1003
|
+
"@types/node": "^22.0.0",
|
|
1004
|
+
"@types/react": "^19.0.0",
|
|
1005
|
+
"@types/react-dom": "^19.0.0"
|
|
1006
|
+
}
|
|
1007
|
+
};
|
|
1008
|
+
await fs.writeJson(path.join(projectDir, "package.json"), pkg, { spaces: 2 });
|
|
1009
|
+
const tsconfig = {
|
|
1010
|
+
compilerOptions: {
|
|
1011
|
+
lib: ["dom", "dom.iterable", "esnext"],
|
|
1012
|
+
allowJs: true,
|
|
1013
|
+
skipLibCheck: true,
|
|
1014
|
+
strict: false,
|
|
1015
|
+
noEmit: true,
|
|
1016
|
+
esModuleInterop: true,
|
|
1017
|
+
module: "esnext",
|
|
1018
|
+
moduleResolution: "bundler",
|
|
1019
|
+
resolveJsonModule: true,
|
|
1020
|
+
isolatedModules: true,
|
|
1021
|
+
jsx: "preserve",
|
|
1022
|
+
incremental: true,
|
|
1023
|
+
plugins: [{ name: "next" }],
|
|
1024
|
+
paths: { "@/*": ["./src/*"] }
|
|
1025
|
+
},
|
|
1026
|
+
include: ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
|
1027
|
+
exclude: ["node_modules"]
|
|
1028
|
+
};
|
|
1029
|
+
await fs.writeJson(path.join(projectDir, "tsconfig.json"), tsconfig, { spaces: 2 });
|
|
1030
|
+
const nextConfig = `/** @type {import('next').NextConfig} */
|
|
1031
|
+
const nextConfig = {
|
|
1032
|
+
images: {
|
|
1033
|
+
remotePatterns: [{ protocol: 'https', hostname: '**' }],
|
|
1034
|
+
unoptimized: true,
|
|
1035
|
+
},
|
|
1036
|
+
};
|
|
1037
|
+
export default nextConfig;
|
|
1038
|
+
`;
|
|
1039
|
+
await fs.writeFile(path.join(projectDir, "next.config.mjs"), nextConfig);
|
|
1040
|
+
const externalCssPath = path.join(projectDir, "src/styles/external.css");
|
|
1041
|
+
const hasExternalCss = await fs.pathExists(externalCssPath);
|
|
1042
|
+
const layout = `import './globals.css';
|
|
1043
|
+
${hasExternalCss ? "import '@/styles/external.css';" : ""}
|
|
1044
|
+
|
|
1045
|
+
export const metadata = {
|
|
1046
|
+
title: '${options.projectName}',
|
|
1047
|
+
description: 'Cloned by extraktor',
|
|
1048
|
+
};
|
|
1049
|
+
|
|
1050
|
+
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
|
1051
|
+
return (
|
|
1052
|
+
<html lang="en">
|
|
1053
|
+
<body>{children}</body>
|
|
1054
|
+
</html>
|
|
1055
|
+
);
|
|
1056
|
+
}
|
|
1057
|
+
`;
|
|
1058
|
+
await fs.writeFile(path.join(projectDir, "src/app/layout.tsx"), layout);
|
|
1059
|
+
const globalsCss = `/* Cloned styles by extraktor */
|
|
1060
|
+
|
|
1061
|
+
/* Reset */
|
|
1062
|
+
* {
|
|
1063
|
+
margin: 0;
|
|
1064
|
+
padding: 0;
|
|
1065
|
+
box-sizing: border-box;
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
html, body {
|
|
1069
|
+
width: 100%;
|
|
1070
|
+
min-height: 100vh;
|
|
1071
|
+
overflow-x: hidden;
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
body {
|
|
1075
|
+
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
/* Keyframe Animations */
|
|
1079
|
+
${keyframes}
|
|
1080
|
+
|
|
1081
|
+
/* Inline Styles from Page */
|
|
1082
|
+
${inlineStyles}
|
|
1083
|
+
`;
|
|
1084
|
+
await fs.writeFile(path.join(projectDir, "src/app/globals.css"), globalsCss);
|
|
1085
|
+
await fs.writeFile(path.join(projectDir, "public/index.html"), htmlContent);
|
|
1086
|
+
await fs.writeFile(path.join(projectDir, "public/page-content.html"), htmlContent);
|
|
1087
|
+
const contentLoaderScript = contentExtracted ? `
|
|
1088
|
+
// Load content customizations
|
|
1089
|
+
const contentScript = document.createElement('script');
|
|
1090
|
+
contentScript.src = '/content-loader.js';
|
|
1091
|
+
document.body.appendChild(contentScript);` : "";
|
|
1092
|
+
const page = `'use client';
|
|
1093
|
+
|
|
1094
|
+
import { useEffect, useState, useRef } from 'react';
|
|
1095
|
+
import { motion } from 'framer-motion';
|
|
1096
|
+
|
|
1097
|
+
export default function Home() {
|
|
1098
|
+
const [html, setHtml] = useState('');
|
|
1099
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
1100
|
+
|
|
1101
|
+
useEffect(() => {
|
|
1102
|
+
fetch('/page-content.html')
|
|
1103
|
+
.then(res => res.text())
|
|
1104
|
+
.then(content => {
|
|
1105
|
+
setHtml(content);
|
|
1106
|
+
});
|
|
1107
|
+
}, []);
|
|
1108
|
+
|
|
1109
|
+
useEffect(() => {
|
|
1110
|
+
if (!containerRef.current || !html) return;
|
|
1111
|
+
|
|
1112
|
+
// Load animation script
|
|
1113
|
+
const script = document.createElement('script');
|
|
1114
|
+
script.src = '/animations.js';
|
|
1115
|
+
document.body.appendChild(script);
|
|
1116
|
+
${contentLoaderScript}
|
|
1117
|
+
|
|
1118
|
+
return () => {
|
|
1119
|
+
script.remove();
|
|
1120
|
+
};
|
|
1121
|
+
}, [html]);
|
|
1122
|
+
|
|
1123
|
+
return (
|
|
1124
|
+
<motion.div
|
|
1125
|
+
initial={{ opacity: 0 }}
|
|
1126
|
+
animate={{ opacity: 1 }}
|
|
1127
|
+
transition={{ duration: 0.3 }}
|
|
1128
|
+
>
|
|
1129
|
+
<div
|
|
1130
|
+
ref={containerRef}
|
|
1131
|
+
dangerouslySetInnerHTML={{ __html: html }}
|
|
1132
|
+
/>
|
|
1133
|
+
</motion.div>
|
|
1134
|
+
);
|
|
1135
|
+
}
|
|
1136
|
+
`;
|
|
1137
|
+
await fs.writeFile(path.join(projectDir, "src/app/page.tsx"), page);
|
|
1138
|
+
}
|
|
1139
|
+
/**
|
|
1140
|
+
* Extract React components from HTML (basic component extraction)
|
|
1141
|
+
*/
|
|
1142
|
+
async extractComponents(page) {
|
|
1143
|
+
return page.evaluate(() => {
|
|
1144
|
+
const components = [];
|
|
1145
|
+
const sections = document.querySelectorAll(
|
|
1146
|
+
'section, [class*="section"], [class*="hero"], [class*="header"], [class*="footer"], [class*="nav"]'
|
|
1147
|
+
);
|
|
1148
|
+
let componentIndex = 0;
|
|
1149
|
+
sections.forEach((section) => {
|
|
1150
|
+
const classes = section.className;
|
|
1151
|
+
let name = "Component";
|
|
1152
|
+
if (classes.includes("hero")) name = "Hero";
|
|
1153
|
+
else if (classes.includes("header")) name = "Header";
|
|
1154
|
+
else if (classes.includes("footer")) name = "Footer";
|
|
1155
|
+
else if (classes.includes("nav")) name = "Navigation";
|
|
1156
|
+
else if (classes.includes("section")) name = "Section";
|
|
1157
|
+
name = `${name}${componentIndex++}`;
|
|
1158
|
+
const computed = getComputedStyle(section);
|
|
1159
|
+
const relevantStyles = [
|
|
1160
|
+
`display: ${computed.display}`,
|
|
1161
|
+
`position: ${computed.position}`,
|
|
1162
|
+
`background: ${computed.background}`,
|
|
1163
|
+
`padding: ${computed.padding}`,
|
|
1164
|
+
`margin: ${computed.margin}`
|
|
1165
|
+
].join("; ");
|
|
1166
|
+
components.push({
|
|
1167
|
+
name,
|
|
1168
|
+
html: section.outerHTML,
|
|
1169
|
+
styles: relevantStyles
|
|
1170
|
+
});
|
|
1171
|
+
});
|
|
1172
|
+
return components;
|
|
1173
|
+
});
|
|
1174
|
+
}
|
|
1175
|
+
};
|
|
1176
|
+
|
|
1177
|
+
// src/genome/genome-engine.ts
|
|
1178
|
+
import fs7 from "fs-extra";
|
|
1179
|
+
import path6 from "path";
|
|
1180
|
+
|
|
1181
|
+
// src/genome/vision-analyzer.ts
|
|
1182
|
+
import Anthropic from "@anthropic-ai/sdk";
|
|
1183
|
+
import sharp from "sharp";
|
|
1184
|
+
import fs2 from "fs-extra";
|
|
1185
|
+
import path2 from "path";
|
|
1186
|
+
var SECTION_SELECTORS = [
|
|
1187
|
+
"header",
|
|
1188
|
+
"nav",
|
|
1189
|
+
"main",
|
|
1190
|
+
"section",
|
|
1191
|
+
"article",
|
|
1192
|
+
"aside",
|
|
1193
|
+
"footer",
|
|
1194
|
+
'[role="banner"]',
|
|
1195
|
+
'[role="navigation"]',
|
|
1196
|
+
'[role="main"]',
|
|
1197
|
+
'[role="contentinfo"]'
|
|
1198
|
+
].join(", ");
|
|
1199
|
+
var VisionAnalyzer = class {
|
|
1200
|
+
client;
|
|
1201
|
+
model;
|
|
1202
|
+
logger;
|
|
1203
|
+
constructor(options) {
|
|
1204
|
+
this.client = new Anthropic({ apiKey: options.apiKey });
|
|
1205
|
+
this.model = options.model ?? "claude-sonnet-4-6-20250514";
|
|
1206
|
+
this.logger = options.logger.child({ prefix: "vision-analyzer" });
|
|
1207
|
+
}
|
|
1208
|
+
// ─── Main Entry Point ───────────────────────────────────────
|
|
1209
|
+
async analyze(page, extractionResult, outputDir) {
|
|
1210
|
+
const screenshotsDir = path2.join(outputDir, "screenshots");
|
|
1211
|
+
await fs2.ensureDir(screenshotsDir);
|
|
1212
|
+
this.logger.info("Capturing full-page screenshot");
|
|
1213
|
+
const fullPageBuffer = await page.screenshot({ fullPage: true });
|
|
1214
|
+
const fullPagePath = path2.join(screenshotsDir, "full-page.png");
|
|
1215
|
+
await fs2.writeFile(fullPagePath, fullPageBuffer);
|
|
1216
|
+
this.logger.info("Resolving section bounding boxes");
|
|
1217
|
+
const sectionBounds = await this.resolveSectionBounds(page);
|
|
1218
|
+
this.logger.info(`Found ${sectionBounds.length} sections`);
|
|
1219
|
+
const imageMetadata = await sharp(fullPageBuffer).metadata();
|
|
1220
|
+
const imageWidth = imageMetadata.width ?? 1920;
|
|
1221
|
+
const imageHeight = imageMetadata.height ?? 1080;
|
|
1222
|
+
const sections = [];
|
|
1223
|
+
for (const section of sectionBounds) {
|
|
1224
|
+
const sectionName = this.buildSectionName(section, sectionBounds);
|
|
1225
|
+
this.logger.info(`Processing section: ${sectionName}`);
|
|
1226
|
+
const cropped = this.clampBounds(section.bounds, imageWidth, imageHeight);
|
|
1227
|
+
if (cropped.width < 2 || cropped.height < 2) {
|
|
1228
|
+
this.logger.warn(`Skipping section ${sectionName}: too small after clamping`);
|
|
1229
|
+
continue;
|
|
1230
|
+
}
|
|
1231
|
+
let sectionBuffer;
|
|
1232
|
+
try {
|
|
1233
|
+
sectionBuffer = await sharp(fullPageBuffer).extract({
|
|
1234
|
+
left: Math.round(cropped.x),
|
|
1235
|
+
top: Math.round(cropped.y),
|
|
1236
|
+
width: Math.round(cropped.width),
|
|
1237
|
+
height: Math.round(cropped.height)
|
|
1238
|
+
}).toBuffer();
|
|
1239
|
+
} catch (err) {
|
|
1240
|
+
this.logger.warn(`Failed to crop section ${sectionName}: ${err}`);
|
|
1241
|
+
continue;
|
|
1242
|
+
}
|
|
1243
|
+
const sectionPath = path2.join(screenshotsDir, `section-${sectionName}.png`);
|
|
1244
|
+
await fs2.writeFile(sectionPath, sectionBuffer);
|
|
1245
|
+
const rawComponents = await this.analyzeSection(sectionBuffer, sectionName);
|
|
1246
|
+
const components = await this.mapToDom(page, rawComponents, section.bounds);
|
|
1247
|
+
sections.push({
|
|
1248
|
+
name: sectionName,
|
|
1249
|
+
screenshotPath: sectionPath,
|
|
1250
|
+
bounds: section.bounds,
|
|
1251
|
+
components
|
|
1252
|
+
});
|
|
1253
|
+
}
|
|
1254
|
+
this.logger.info("Analyzing design language");
|
|
1255
|
+
const designLanguage = await this.analyzeDesignLanguage(fullPageBuffer);
|
|
1256
|
+
this.logger.info("Building component hierarchy");
|
|
1257
|
+
const hierarchy = this.buildHierarchy(sections);
|
|
1258
|
+
return { sections, hierarchy, designLanguage };
|
|
1259
|
+
}
|
|
1260
|
+
// ─── Section Bounds Resolution ──────────────────────────────
|
|
1261
|
+
async resolveSectionBounds(page) {
|
|
1262
|
+
const rawBounds = await page.evaluate((selector) => {
|
|
1263
|
+
const elements = document.querySelectorAll(selector);
|
|
1264
|
+
const results = [];
|
|
1265
|
+
elements.forEach((el) => {
|
|
1266
|
+
const rect = el.getBoundingClientRect();
|
|
1267
|
+
const style = window.getComputedStyle(el);
|
|
1268
|
+
if (style.display === "none" || style.visibility === "hidden" || parseFloat(style.opacity) === 0 || rect.width < 10 && rect.height < 10) {
|
|
1269
|
+
return;
|
|
1270
|
+
}
|
|
1271
|
+
results.push({
|
|
1272
|
+
tag: el.tagName.toLowerCase(),
|
|
1273
|
+
role: el.getAttribute("role") || "",
|
|
1274
|
+
bounds: {
|
|
1275
|
+
x: rect.x + window.scrollX,
|
|
1276
|
+
y: rect.y + window.scrollY,
|
|
1277
|
+
width: rect.width,
|
|
1278
|
+
height: rect.height
|
|
1279
|
+
}
|
|
1280
|
+
});
|
|
1281
|
+
});
|
|
1282
|
+
return results;
|
|
1283
|
+
}, SECTION_SELECTORS);
|
|
1284
|
+
rawBounds.sort((a, b) => a.bounds.y - b.bounds.y);
|
|
1285
|
+
return rawBounds.map((r) => ({
|
|
1286
|
+
name: r.role || r.tag,
|
|
1287
|
+
tag: r.tag,
|
|
1288
|
+
bounds: r.bounds
|
|
1289
|
+
}));
|
|
1290
|
+
}
|
|
1291
|
+
// ─── Section Naming ─────────────────────────────────────────
|
|
1292
|
+
buildSectionName(section, allSections) {
|
|
1293
|
+
const sameName = allSections.filter((s) => (s.name || s.tag) === (section.name || section.tag));
|
|
1294
|
+
const baseName = section.name || section.tag;
|
|
1295
|
+
if (sameName.length <= 1) return baseName;
|
|
1296
|
+
const idx = sameName.indexOf(section);
|
|
1297
|
+
return `${baseName}-${idx + 1}`;
|
|
1298
|
+
}
|
|
1299
|
+
// ─── Bounds Clamping ────────────────────────────────────────
|
|
1300
|
+
clampBounds(bounds, imageWidth, imageHeight) {
|
|
1301
|
+
const x = Math.max(0, Math.min(bounds.x, imageWidth - 1));
|
|
1302
|
+
const y = Math.max(0, Math.min(bounds.y, imageHeight - 1));
|
|
1303
|
+
const width = Math.min(bounds.width, imageWidth - x);
|
|
1304
|
+
const height = Math.min(bounds.height, imageHeight - y);
|
|
1305
|
+
return { x, y, width: Math.max(1, width), height: Math.max(1, height) };
|
|
1306
|
+
}
|
|
1307
|
+
// ─── Section Analysis via Claude Vision ─────────────────────
|
|
1308
|
+
async analyzeSection(buffer, sectionName) {
|
|
1309
|
+
const base64 = buffer.toString("base64");
|
|
1310
|
+
const prompt = `Analyze this UI section screenshot ("${sectionName}") and identify all visual components.
|
|
1311
|
+
|
|
1312
|
+
Return a JSON array of components. Each component should have:
|
|
1313
|
+
- name: descriptive component name (e.g. "PrimaryNavLink", "HeroHeading")
|
|
1314
|
+
- type: component type (e.g. "button", "heading", "card", "image", "nav-link", "input", "icon", "badge", "avatar")
|
|
1315
|
+
- bounds: { x, y, width, height } in pixels relative to this cropped section image
|
|
1316
|
+
- description: what the component does/shows
|
|
1317
|
+
- designNotes: visual design observations (colors, typography, spacing, effects)
|
|
1318
|
+
- children: nested components (same structure), empty array if none
|
|
1319
|
+
- variants: visual states or variations observed (e.g. ["hover", "active", "disabled"])
|
|
1320
|
+
|
|
1321
|
+
Return ONLY valid JSON. No markdown, no explanation. Just the JSON array.`;
|
|
1322
|
+
const components = await this.callVisionApi(base64, prompt);
|
|
1323
|
+
return components ?? [];
|
|
1324
|
+
}
|
|
1325
|
+
// ─── DOM Mapping ────────────────────────────────────────────
|
|
1326
|
+
async mapToDom(page, rawComponents, sectionBounds) {
|
|
1327
|
+
const mapped = [];
|
|
1328
|
+
for (const raw of rawComponents) {
|
|
1329
|
+
const absX = sectionBounds.x + raw.bounds.x;
|
|
1330
|
+
const absY = sectionBounds.y + raw.bounds.y;
|
|
1331
|
+
const centerX = absX + raw.bounds.width / 2;
|
|
1332
|
+
const centerY = absY + raw.bounds.height / 2;
|
|
1333
|
+
const domInfo = await page.evaluate(
|
|
1334
|
+
({ cx, cy, maxHtml }) => {
|
|
1335
|
+
const viewX = cx - window.scrollX;
|
|
1336
|
+
const viewY = cy - window.scrollY;
|
|
1337
|
+
const el = document.elementFromPoint(viewX, viewY);
|
|
1338
|
+
if (!el) return null;
|
|
1339
|
+
const buildSelector = (element) => {
|
|
1340
|
+
if (element.id) return `#${element.id}`;
|
|
1341
|
+
const tag = element.tagName.toLowerCase();
|
|
1342
|
+
const classes = Array.from(element.classList).slice(0, 3).join(".");
|
|
1343
|
+
const parent = element.parentElement;
|
|
1344
|
+
if (!parent) return tag;
|
|
1345
|
+
const siblings = Array.from(parent.children).filter(
|
|
1346
|
+
(s) => s.tagName === element.tagName
|
|
1347
|
+
);
|
|
1348
|
+
const idx = siblings.indexOf(element);
|
|
1349
|
+
const nthChild = siblings.length > 1 ? `:nth-child(${idx + 1})` : "";
|
|
1350
|
+
return classes ? `${tag}.${classes}${nthChild}` : `${tag}${nthChild}`;
|
|
1351
|
+
};
|
|
1352
|
+
const selector = buildSelector(el);
|
|
1353
|
+
let html = el.outerHTML;
|
|
1354
|
+
if (html.length > maxHtml) {
|
|
1355
|
+
html = html.slice(0, maxHtml) + "<!-- truncated -->";
|
|
1356
|
+
}
|
|
1357
|
+
return { selector, html };
|
|
1358
|
+
},
|
|
1359
|
+
{ cx: centerX, cy: centerY, maxHtml: 3e3 }
|
|
1360
|
+
);
|
|
1361
|
+
const children = await this.mapToDom(page, raw.children, sectionBounds);
|
|
1362
|
+
mapped.push({
|
|
1363
|
+
name: raw.name,
|
|
1364
|
+
type: raw.type,
|
|
1365
|
+
bounds: {
|
|
1366
|
+
x: absX,
|
|
1367
|
+
y: absY,
|
|
1368
|
+
width: raw.bounds.width,
|
|
1369
|
+
height: raw.bounds.height
|
|
1370
|
+
},
|
|
1371
|
+
description: raw.description,
|
|
1372
|
+
designNotes: raw.designNotes,
|
|
1373
|
+
domSelector: domInfo?.selector ?? "",
|
|
1374
|
+
domHtml: domInfo?.html ?? "",
|
|
1375
|
+
children,
|
|
1376
|
+
variants: raw.variants
|
|
1377
|
+
});
|
|
1378
|
+
}
|
|
1379
|
+
return mapped;
|
|
1380
|
+
}
|
|
1381
|
+
// ─── Design Language Analysis ───────────────────────────────
|
|
1382
|
+
async analyzeDesignLanguage(fullPageBuffer) {
|
|
1383
|
+
const base64 = fullPageBuffer.toString("base64");
|
|
1384
|
+
const prompt = `Analyze the overall design language of this full-page screenshot.
|
|
1385
|
+
|
|
1386
|
+
Return a JSON object with exactly these fields:
|
|
1387
|
+
- colorStrategy: Describe the color palette strategy (primary/secondary/accent usage, contrast approach, dark/light mode handling)
|
|
1388
|
+
- typographyHierarchy: Describe the typography system (heading scales, body text, font families, weight patterns, line-height rhythm)
|
|
1389
|
+
- spacingRhythm: Describe the spacing system (consistent spacing units, section padding patterns, component gaps, margin rhythm)
|
|
1390
|
+
- animationStyle: Describe the animation/motion design approach (entrance animations, hover effects, transition timing, scroll behaviors observed)
|
|
1391
|
+
- overallAesthetic: Describe the overall design aesthetic in 2-3 sentences (modern/classic, minimal/rich, corporate/playful, design influences)
|
|
1392
|
+
|
|
1393
|
+
Return ONLY valid JSON. No markdown, no explanation. Just the JSON object.`;
|
|
1394
|
+
const result = await this.callVisionApi(base64, prompt);
|
|
1395
|
+
return result ?? {
|
|
1396
|
+
colorStrategy: "Unable to analyze",
|
|
1397
|
+
typographyHierarchy: "Unable to analyze",
|
|
1398
|
+
spacingRhythm: "Unable to analyze",
|
|
1399
|
+
animationStyle: "Unable to analyze",
|
|
1400
|
+
overallAesthetic: "Unable to analyze"
|
|
1401
|
+
};
|
|
1402
|
+
}
|
|
1403
|
+
// ─── Hierarchy Builder ──────────────────────────────────────
|
|
1404
|
+
buildHierarchy(sections) {
|
|
1405
|
+
const tree = [];
|
|
1406
|
+
for (const section of sections) {
|
|
1407
|
+
for (const component of section.components) {
|
|
1408
|
+
tree.push(this.componentToTreeNode(component));
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
return tree;
|
|
1412
|
+
}
|
|
1413
|
+
componentToTreeNode(component) {
|
|
1414
|
+
return {
|
|
1415
|
+
component,
|
|
1416
|
+
children: component.children.map((child) => this.componentToTreeNode(child))
|
|
1417
|
+
};
|
|
1418
|
+
}
|
|
1419
|
+
// ─── Vision API Helper ──────────────────────────────────────
|
|
1420
|
+
async callVisionApi(base64, prompt) {
|
|
1421
|
+
let attempts = 0;
|
|
1422
|
+
const maxAttempts = 2;
|
|
1423
|
+
while (attempts < maxAttempts) {
|
|
1424
|
+
attempts++;
|
|
1425
|
+
try {
|
|
1426
|
+
const response = await this.client.messages.create({
|
|
1427
|
+
model: this.model,
|
|
1428
|
+
max_tokens: 4096,
|
|
1429
|
+
messages: [
|
|
1430
|
+
{
|
|
1431
|
+
role: "user",
|
|
1432
|
+
content: [
|
|
1433
|
+
{
|
|
1434
|
+
type: "image",
|
|
1435
|
+
source: {
|
|
1436
|
+
type: "base64",
|
|
1437
|
+
media_type: "image/png",
|
|
1438
|
+
data: base64
|
|
1439
|
+
}
|
|
1440
|
+
},
|
|
1441
|
+
{
|
|
1442
|
+
type: "text",
|
|
1443
|
+
text: prompt
|
|
1444
|
+
}
|
|
1445
|
+
]
|
|
1446
|
+
}
|
|
1447
|
+
]
|
|
1448
|
+
});
|
|
1449
|
+
const textBlock = response.content.find((block) => block.type === "text");
|
|
1450
|
+
if (!textBlock || textBlock.type !== "text") {
|
|
1451
|
+
this.logger.warn("No text block in vision response");
|
|
1452
|
+
continue;
|
|
1453
|
+
}
|
|
1454
|
+
const rawText = textBlock.text.trim();
|
|
1455
|
+
const jsonText = rawText.replace(/^```(?:json)?\s*\n?/i, "").replace(/\n?```\s*$/i, "").trim();
|
|
1456
|
+
const parsed = JSON.parse(jsonText);
|
|
1457
|
+
return parsed;
|
|
1458
|
+
} catch (err) {
|
|
1459
|
+
if (attempts < maxAttempts) {
|
|
1460
|
+
this.logger.warn(
|
|
1461
|
+
`Vision API parse failed (attempt ${attempts}/${maxAttempts}), retrying: ${err}`
|
|
1462
|
+
);
|
|
1463
|
+
} else {
|
|
1464
|
+
this.logger.error(`Vision API failed after ${maxAttempts} attempts: ${err}`);
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
}
|
|
1468
|
+
return null;
|
|
1469
|
+
}
|
|
1470
|
+
};
|
|
1471
|
+
|
|
1472
|
+
// src/genome/component-synthesizer.ts
|
|
1473
|
+
import Anthropic2 from "@anthropic-ai/sdk";
|
|
1474
|
+
import fs3 from "fs-extra";
|
|
1475
|
+
var TRIVIAL_TYPES = /* @__PURE__ */ new Set(["divider", "icon-group"]);
|
|
1476
|
+
var BATCH_SIZE = 3;
|
|
1477
|
+
var BATCH_DELAY_MS = 500;
|
|
1478
|
+
var SYSTEM_PROMPT = `You are an expert React/TypeScript developer. You receive two inputs for each component:
|
|
1479
|
+
|
|
1480
|
+
1. A SCREENSHOT of the page section (visual ground truth)
|
|
1481
|
+
2. An HTML DOM fragment (structural hints)
|
|
1482
|
+
|
|
1483
|
+
Your job: produce a single, clean React component that faithfully reproduces the visual design.
|
|
1484
|
+
|
|
1485
|
+
RULES:
|
|
1486
|
+
- Use Tailwind CSS utility classes exclusively. No inline styles, no CSS modules, no styled-components.
|
|
1487
|
+
- Use semantic HTML elements (section, nav, header, article, figure, etc.).
|
|
1488
|
+
- Define a TypeScript props interface for ALL editable content (headings, body text, images, links, button labels, stats, etc.). Name it <ComponentName>Props.
|
|
1489
|
+
- Add framer-motion entrance animations (fadeIn, slideUp, stagger children) using the motion component.
|
|
1490
|
+
- Provide both a named export and a default export.
|
|
1491
|
+
- Add the 'use client' directive at the top of the file.
|
|
1492
|
+
- Write clean, readable code with consistent formatting.
|
|
1493
|
+
- Return ONLY the TypeScript/React code. No markdown fences, no explanations, no commentary.
|
|
1494
|
+
- Start directly with 'use client' on line 1.`;
|
|
1495
|
+
var ComponentSynthesizer = class {
|
|
1496
|
+
client;
|
|
1497
|
+
model;
|
|
1498
|
+
maxComponents;
|
|
1499
|
+
logger;
|
|
1500
|
+
constructor(options) {
|
|
1501
|
+
this.client = new Anthropic2({ apiKey: options.apiKey });
|
|
1502
|
+
this.model = options.model ?? "claude-sonnet-4-6-20250514";
|
|
1503
|
+
this.maxComponents = options.maxComponents ?? 20;
|
|
1504
|
+
this.logger = options.logger;
|
|
1505
|
+
}
|
|
1506
|
+
// ─── Main Entry Point ──────────────────────────────────────
|
|
1507
|
+
async synthesize(visionMap, extractionResult) {
|
|
1508
|
+
const tokensSummary = this.buildTokensSummary(extractionResult);
|
|
1509
|
+
const results = [];
|
|
1510
|
+
let totalProcessed = 0;
|
|
1511
|
+
for (const section of visionMap.sections) {
|
|
1512
|
+
if (totalProcessed >= this.maxComponents) break;
|
|
1513
|
+
const eligibleComponents = section.components.filter(
|
|
1514
|
+
(c) => !TRIVIAL_TYPES.has(c.type)
|
|
1515
|
+
);
|
|
1516
|
+
if (eligibleComponents.length === 0) {
|
|
1517
|
+
this.logger?.debug(`Skipping section "${section.name}" - no eligible components`);
|
|
1518
|
+
continue;
|
|
1519
|
+
}
|
|
1520
|
+
this.logger?.info(
|
|
1521
|
+
`Processing section "${section.name}" (${eligibleComponents.length} components)`
|
|
1522
|
+
);
|
|
1523
|
+
let screenshotBase64 = null;
|
|
1524
|
+
if (section.screenshotPath && await fs3.pathExists(section.screenshotPath)) {
|
|
1525
|
+
const buffer = await fs3.readFile(section.screenshotPath);
|
|
1526
|
+
screenshotBase64 = buffer.toString("base64");
|
|
1527
|
+
} else {
|
|
1528
|
+
this.logger?.warn(
|
|
1529
|
+
`Screenshot not found for section "${section.name}": ${section.screenshotPath}`
|
|
1530
|
+
);
|
|
1531
|
+
}
|
|
1532
|
+
for (let i = 0; i < eligibleComponents.length; i += BATCH_SIZE) {
|
|
1533
|
+
if (totalProcessed >= this.maxComponents) break;
|
|
1534
|
+
const remaining = this.maxComponents - totalProcessed;
|
|
1535
|
+
const batch = eligibleComponents.slice(i, Math.min(i + BATCH_SIZE, i + remaining));
|
|
1536
|
+
const batchResults = await Promise.all(
|
|
1537
|
+
batch.map(
|
|
1538
|
+
(comp) => this.synthesizeComponent(comp, section, screenshotBase64, tokensSummary)
|
|
1539
|
+
)
|
|
1540
|
+
);
|
|
1541
|
+
for (const result of batchResults) {
|
|
1542
|
+
if (result) {
|
|
1543
|
+
results.push(result);
|
|
1544
|
+
totalProcessed++;
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
1547
|
+
if (i + BATCH_SIZE < eligibleComponents.length && totalProcessed < this.maxComponents) {
|
|
1548
|
+
await new Promise((resolve) => setTimeout(resolve, BATCH_DELAY_MS));
|
|
1549
|
+
}
|
|
1550
|
+
}
|
|
1551
|
+
}
|
|
1552
|
+
this.logger?.info(`Synthesized ${results.length} components total`);
|
|
1553
|
+
return results;
|
|
1554
|
+
}
|
|
1555
|
+
// ─── Single Component Synthesis ────────────────────────────
|
|
1556
|
+
async synthesizeComponent(comp, section, screenshotBase64, tokensSummary) {
|
|
1557
|
+
try {
|
|
1558
|
+
this.logger?.debug(`Synthesizing component "${comp.name}" (${comp.type})`);
|
|
1559
|
+
const userPrompt = this.buildUserPrompt(comp, tokensSummary);
|
|
1560
|
+
const content = [];
|
|
1561
|
+
if (screenshotBase64) {
|
|
1562
|
+
content.push({
|
|
1563
|
+
type: "image",
|
|
1564
|
+
source: {
|
|
1565
|
+
type: "base64",
|
|
1566
|
+
media_type: "image/png",
|
|
1567
|
+
data: screenshotBase64
|
|
1568
|
+
}
|
|
1569
|
+
});
|
|
1570
|
+
}
|
|
1571
|
+
content.push({
|
|
1572
|
+
type: "text",
|
|
1573
|
+
text: userPrompt
|
|
1574
|
+
});
|
|
1575
|
+
const response = await this.client.messages.create({
|
|
1576
|
+
model: this.model,
|
|
1577
|
+
max_tokens: 4096,
|
|
1578
|
+
system: SYSTEM_PROMPT,
|
|
1579
|
+
messages: [{ role: "user", content }]
|
|
1580
|
+
});
|
|
1581
|
+
const textBlock = response.content.find((block) => block.type === "text");
|
|
1582
|
+
if (!textBlock || textBlock.type !== "text") {
|
|
1583
|
+
this.logger?.warn(`No text response for component "${comp.name}"`);
|
|
1584
|
+
return null;
|
|
1585
|
+
}
|
|
1586
|
+
let code = textBlock.text.replace(/^```(?:tsx?|typescript|javascript)?\n?/gm, "").replace(/```$/gm, "").trim();
|
|
1587
|
+
const propsInterface = this.extractPropsInterface(code);
|
|
1588
|
+
const dependencies = this.extractDependencies(code);
|
|
1589
|
+
const defaultProps = this.inferDefaultProps(code, comp);
|
|
1590
|
+
const designRelationships = {
|
|
1591
|
+
usesColors: this.detectColorUsage(code),
|
|
1592
|
+
usesTypography: this.detectTypographyUsage(code),
|
|
1593
|
+
usesSpacing: this.detectSpacingUsage(code)
|
|
1594
|
+
};
|
|
1595
|
+
const synthesized = {
|
|
1596
|
+
name: comp.name,
|
|
1597
|
+
type: comp.type,
|
|
1598
|
+
code,
|
|
1599
|
+
propsInterface,
|
|
1600
|
+
storyCode: "",
|
|
1601
|
+
// Story generation is handled separately
|
|
1602
|
+
defaultProps,
|
|
1603
|
+
dependencies,
|
|
1604
|
+
designRelationships
|
|
1605
|
+
};
|
|
1606
|
+
this.logger?.info(`Synthesized "${comp.name}" (${dependencies.length} deps)`);
|
|
1607
|
+
return synthesized;
|
|
1608
|
+
} catch (error) {
|
|
1609
|
+
this.logger?.error(
|
|
1610
|
+
`Failed to synthesize "${comp.name}": ${error instanceof Error ? error.message : String(error)}`
|
|
1611
|
+
);
|
|
1612
|
+
return null;
|
|
1613
|
+
}
|
|
1614
|
+
}
|
|
1615
|
+
// ─── Prompt Builders ───────────────────────────────────────
|
|
1616
|
+
buildUserPrompt(comp, tokensSummary) {
|
|
1617
|
+
const lines = [
|
|
1618
|
+
`Generate a React component named "${comp.name}".`,
|
|
1619
|
+
"",
|
|
1620
|
+
`COMPONENT TYPE: ${comp.type}`,
|
|
1621
|
+
`DESCRIPTION: ${comp.description}`
|
|
1622
|
+
];
|
|
1623
|
+
if (comp.designNotes) {
|
|
1624
|
+
lines.push(`DESIGN NOTES: ${comp.designNotes}`);
|
|
1625
|
+
}
|
|
1626
|
+
if (comp.variants.length > 0) {
|
|
1627
|
+
lines.push(`VARIANTS: ${comp.variants.join(", ")}`);
|
|
1628
|
+
}
|
|
1629
|
+
lines.push("", "--- DESIGN TOKENS ---", tokensSummary);
|
|
1630
|
+
if (comp.domHtml) {
|
|
1631
|
+
lines.push(
|
|
1632
|
+
"",
|
|
1633
|
+
"--- DOM FRAGMENT (structural hint, do NOT copy verbatim) ---",
|
|
1634
|
+
comp.domHtml
|
|
1635
|
+
);
|
|
1636
|
+
}
|
|
1637
|
+
lines.push(
|
|
1638
|
+
"",
|
|
1639
|
+
"The screenshot above is the visual ground truth. Match it precisely.",
|
|
1640
|
+
"Return ONLY the component code, starting with 'use client';"
|
|
1641
|
+
);
|
|
1642
|
+
return lines.join("\n");
|
|
1643
|
+
}
|
|
1644
|
+
buildTokensSummary(result) {
|
|
1645
|
+
const lines = [];
|
|
1646
|
+
if (result.colors?.palette?.length) {
|
|
1647
|
+
const colors = result.colors.palette.slice(0, 15).map((c) => `${c.name}: ${c.value}`).join(", ");
|
|
1648
|
+
lines.push(`COLORS: ${colors}`);
|
|
1649
|
+
}
|
|
1650
|
+
if (result.typography?.fontFamilies?.length) {
|
|
1651
|
+
const fonts = result.typography.fontFamilies.slice(0, 5).map((f) => `${f.name}: ${f.value}`).join(", ");
|
|
1652
|
+
lines.push(`FONTS: ${fonts}`);
|
|
1653
|
+
}
|
|
1654
|
+
if (result.typography?.fontSizes?.length) {
|
|
1655
|
+
const sizes = result.typography.fontSizes.slice(0, 8).map((s) => `${s.name}: ${s.value}`).join(", ");
|
|
1656
|
+
lines.push(`SIZES: ${sizes}`);
|
|
1657
|
+
}
|
|
1658
|
+
if (result.spacing?.scale?.length) {
|
|
1659
|
+
const spacing = result.spacing.scale.slice(0, 10).map((s) => `${s.name}: ${s.value}`).join(", ");
|
|
1660
|
+
lines.push(`SPACING: ${spacing}`);
|
|
1661
|
+
}
|
|
1662
|
+
if (result.effects?.borderRadii?.length) {
|
|
1663
|
+
const radii = result.effects.borderRadii.slice(0, 5).map((r) => `${r.name}: ${r.value}`).join(", ");
|
|
1664
|
+
lines.push(`RADII: ${radii}`);
|
|
1665
|
+
}
|
|
1666
|
+
if (result.effects?.shadows?.length) {
|
|
1667
|
+
const shadows = result.effects.shadows.slice(0, 3).map((s) => `${s.name}: ${s.value}`).join(", ");
|
|
1668
|
+
lines.push(`SHADOWS: ${shadows}`);
|
|
1669
|
+
}
|
|
1670
|
+
return lines.join("\n");
|
|
1671
|
+
}
|
|
1672
|
+
// ─── Code Analysis Helpers ─────────────────────────────────
|
|
1673
|
+
extractPropsInterface(code) {
|
|
1674
|
+
const match = code.match(/interface\s+\w*Props\s*\{[^}]*\}/s);
|
|
1675
|
+
return match ? match[0] : "";
|
|
1676
|
+
}
|
|
1677
|
+
extractDependencies(code) {
|
|
1678
|
+
const deps = [];
|
|
1679
|
+
if (/from\s+['"]framer-motion['"]/.test(code)) {
|
|
1680
|
+
deps.push("framer-motion");
|
|
1681
|
+
}
|
|
1682
|
+
if (/from\s+['"]lucide-react['"]/.test(code)) {
|
|
1683
|
+
deps.push("lucide-react");
|
|
1684
|
+
}
|
|
1685
|
+
if (/from\s+['"]next\/image['"]/.test(code)) {
|
|
1686
|
+
deps.push("next/image");
|
|
1687
|
+
}
|
|
1688
|
+
if (/from\s+['"]next\/link['"]/.test(code)) {
|
|
1689
|
+
deps.push("next/link");
|
|
1690
|
+
}
|
|
1691
|
+
return deps;
|
|
1692
|
+
}
|
|
1693
|
+
inferDefaultProps(code, comp) {
|
|
1694
|
+
const defaults = {};
|
|
1695
|
+
const destructureMatch = code.match(/\{\s*([^}]+)\}\s*:\s*\w*Props/);
|
|
1696
|
+
if (destructureMatch) {
|
|
1697
|
+
const propsBlock = destructureMatch[1];
|
|
1698
|
+
const defaultRegex = /(\w+)\s*=\s*(?:"([^"]*)"|'([^']*)'|(\d+(?:\.\d+)?)|(\[.*?\])|(\{.*?\})|(true|false))/g;
|
|
1699
|
+
let match;
|
|
1700
|
+
while ((match = defaultRegex.exec(propsBlock)) !== null) {
|
|
1701
|
+
const propName = match[1];
|
|
1702
|
+
const strVal = match[2] ?? match[3];
|
|
1703
|
+
const numVal = match[4];
|
|
1704
|
+
const arrVal = match[5];
|
|
1705
|
+
const objVal = match[6];
|
|
1706
|
+
const boolVal = match[7];
|
|
1707
|
+
if (strVal !== void 0) {
|
|
1708
|
+
defaults[propName] = strVal;
|
|
1709
|
+
} else if (numVal !== void 0) {
|
|
1710
|
+
defaults[propName] = parseFloat(numVal);
|
|
1711
|
+
} else if (boolVal !== void 0) {
|
|
1712
|
+
defaults[propName] = boolVal === "true";
|
|
1713
|
+
} else if (arrVal !== void 0) {
|
|
1714
|
+
try {
|
|
1715
|
+
defaults[propName] = JSON.parse(arrVal);
|
|
1716
|
+
} catch {
|
|
1717
|
+
defaults[propName] = arrVal;
|
|
1718
|
+
}
|
|
1719
|
+
} else if (objVal !== void 0) {
|
|
1720
|
+
try {
|
|
1721
|
+
defaults[propName] = JSON.parse(objVal);
|
|
1722
|
+
} catch {
|
|
1723
|
+
defaults[propName] = objVal;
|
|
1724
|
+
}
|
|
1725
|
+
}
|
|
1726
|
+
}
|
|
1727
|
+
}
|
|
1728
|
+
if (!defaults["title"] && !defaults["heading"] && comp.description) {
|
|
1729
|
+
defaults["title"] = comp.name.replace(/([A-Z])/g, " $1").trim();
|
|
1730
|
+
}
|
|
1731
|
+
return defaults;
|
|
1732
|
+
}
|
|
1733
|
+
detectColorUsage(code) {
|
|
1734
|
+
const colorClasses = /* @__PURE__ */ new Set();
|
|
1735
|
+
const colorRegex = /(?:bg|text|border|ring|from|to|via)-([a-z]+-\d{2,3}(?:\/\d+)?|black|white|transparent|current)/g;
|
|
1736
|
+
let match;
|
|
1737
|
+
while ((match = colorRegex.exec(code)) !== null) {
|
|
1738
|
+
colorClasses.add(match[0]);
|
|
1739
|
+
}
|
|
1740
|
+
return Array.from(colorClasses);
|
|
1741
|
+
}
|
|
1742
|
+
detectTypographyUsage(code) {
|
|
1743
|
+
const typoClasses = /* @__PURE__ */ new Set();
|
|
1744
|
+
const typoRegex = /(?:text-(?:xs|sm|base|lg|xl|2xl|3xl|4xl|5xl|6xl|7xl|8xl|9xl)|font-(?:thin|extralight|light|normal|medium|semibold|bold|extrabold|black|sans|serif|mono))/g;
|
|
1745
|
+
let match;
|
|
1746
|
+
while ((match = typoRegex.exec(code)) !== null) {
|
|
1747
|
+
typoClasses.add(match[0]);
|
|
1748
|
+
}
|
|
1749
|
+
return Array.from(typoClasses);
|
|
1750
|
+
}
|
|
1751
|
+
detectSpacingUsage(code) {
|
|
1752
|
+
const spacingClasses = /* @__PURE__ */ new Set();
|
|
1753
|
+
const spacingRegex = /(?:p|m|gap|px|py|mx|my|pt|pr|pb|pl|mt|mr|mb|ml)-(?:\d+(?:\.\d+)?|\[[\d.]+(?:px|rem|em|%)\]|auto)/g;
|
|
1754
|
+
let match;
|
|
1755
|
+
while ((match = spacingRegex.exec(code)) !== null) {
|
|
1756
|
+
spacingClasses.add(match[0]);
|
|
1757
|
+
}
|
|
1758
|
+
return Array.from(spacingClasses);
|
|
1759
|
+
}
|
|
1760
|
+
};
|
|
1761
|
+
|
|
1762
|
+
// src/genome/story-generator.ts
|
|
1763
|
+
import fs4 from "fs-extra";
|
|
1764
|
+
import path3 from "path";
|
|
1765
|
+
var StoryGenerator = class {
|
|
1766
|
+
logger;
|
|
1767
|
+
constructor(options) {
|
|
1768
|
+
this.logger = options.logger;
|
|
1769
|
+
}
|
|
1770
|
+
/**
|
|
1771
|
+
* Generate Storybook stories and configuration for all components.
|
|
1772
|
+
*/
|
|
1773
|
+
async generate(components, outputDir) {
|
|
1774
|
+
const storiesDir = path3.join(outputDir, "stories");
|
|
1775
|
+
const storybookDir = path3.join(storiesDir, ".storybook");
|
|
1776
|
+
await fs4.ensureDir(storiesDir);
|
|
1777
|
+
await fs4.ensureDir(storybookDir);
|
|
1778
|
+
this.logger.info(`Generating stories for ${components.length} components`);
|
|
1779
|
+
for (const comp of components) {
|
|
1780
|
+
const storyCode = this.generateComponentStory(comp);
|
|
1781
|
+
comp.storyCode = storyCode;
|
|
1782
|
+
const storyFile = path3.join(storiesDir, `${comp.name}.stories.tsx`);
|
|
1783
|
+
await fs4.writeFile(storyFile, storyCode, "utf-8");
|
|
1784
|
+
this.logger.debug(`Wrote story: ${storyFile}`);
|
|
1785
|
+
}
|
|
1786
|
+
const mainConfig = this.generateMainConfig();
|
|
1787
|
+
await fs4.writeFile(path3.join(storybookDir, "main.ts"), mainConfig, "utf-8");
|
|
1788
|
+
this.logger.debug("Wrote .storybook/main.ts");
|
|
1789
|
+
const previewConfig = this.generatePreviewConfig();
|
|
1790
|
+
await fs4.writeFile(path3.join(storybookDir, "preview.ts"), previewConfig, "utf-8");
|
|
1791
|
+
this.logger.debug("Wrote .storybook/preview.ts");
|
|
1792
|
+
this.logger.info(`Storybook generation complete: ${components.length} stories written`);
|
|
1793
|
+
}
|
|
1794
|
+
/**
|
|
1795
|
+
* Generate a .stories.tsx file for a single component.
|
|
1796
|
+
*/
|
|
1797
|
+
generateComponentStory(comp) {
|
|
1798
|
+
const filteredProps = this.filterDefaultProps(comp.defaultProps);
|
|
1799
|
+
const propsArg = JSON.stringify(filteredProps, null, 2);
|
|
1800
|
+
const indentedProps = propsArg.split("\n").map((line, i) => i === 0 ? line : ` ${line}`).join("\n");
|
|
1801
|
+
return `import type { Meta, StoryObj } from '@storybook/react';
|
|
1802
|
+
import { ${comp.name} } from '../components/${comp.name}';
|
|
1803
|
+
|
|
1804
|
+
const meta: Meta<typeof ${comp.name}> = {
|
|
1805
|
+
title: 'Components/${comp.name}',
|
|
1806
|
+
component: ${comp.name},
|
|
1807
|
+
parameters: {
|
|
1808
|
+
layout: 'centered',
|
|
1809
|
+
},
|
|
1810
|
+
tags: ['autodocs'],
|
|
1811
|
+
};
|
|
1812
|
+
|
|
1813
|
+
export default meta;
|
|
1814
|
+
type Story = StoryObj<typeof ${comp.name}>;
|
|
1815
|
+
|
|
1816
|
+
export const Default: Story = {
|
|
1817
|
+
args: ${indentedProps},
|
|
1818
|
+
};
|
|
1819
|
+
|
|
1820
|
+
export const Mobile: Story = {
|
|
1821
|
+
args: ${indentedProps},
|
|
1822
|
+
parameters: {
|
|
1823
|
+
viewport: {
|
|
1824
|
+
defaultViewport: 'mobile1',
|
|
1825
|
+
},
|
|
1826
|
+
},
|
|
1827
|
+
};
|
|
1828
|
+
|
|
1829
|
+
export const Tablet: Story = {
|
|
1830
|
+
args: ${indentedProps},
|
|
1831
|
+
parameters: {
|
|
1832
|
+
viewport: {
|
|
1833
|
+
defaultViewport: 'tablet',
|
|
1834
|
+
},
|
|
1835
|
+
},
|
|
1836
|
+
};
|
|
1837
|
+
`;
|
|
1838
|
+
}
|
|
1839
|
+
/**
|
|
1840
|
+
* Filter out internal props (keys starting with _) from defaultProps.
|
|
1841
|
+
*/
|
|
1842
|
+
filterDefaultProps(defaultProps) {
|
|
1843
|
+
const filtered = {};
|
|
1844
|
+
for (const [key, value] of Object.entries(defaultProps)) {
|
|
1845
|
+
if (!key.startsWith("_")) {
|
|
1846
|
+
filtered[key] = value;
|
|
1847
|
+
}
|
|
1848
|
+
}
|
|
1849
|
+
return filtered;
|
|
1850
|
+
}
|
|
1851
|
+
/**
|
|
1852
|
+
* Generate .storybook/main.ts configuration.
|
|
1853
|
+
*/
|
|
1854
|
+
generateMainConfig() {
|
|
1855
|
+
return `import type { StorybookConfig } from '@storybook/react-vite';
|
|
1856
|
+
|
|
1857
|
+
const config: StorybookConfig = {
|
|
1858
|
+
framework: {
|
|
1859
|
+
name: '@storybook/react-vite',
|
|
1860
|
+
options: {},
|
|
1861
|
+
},
|
|
1862
|
+
stories: ['../*.stories.@(ts|tsx)'],
|
|
1863
|
+
addons: [
|
|
1864
|
+
'@storybook/addon-essentials',
|
|
1865
|
+
'@storybook/addon-a11y',
|
|
1866
|
+
],
|
|
1867
|
+
docs: {
|
|
1868
|
+
autodocs: true,
|
|
1869
|
+
},
|
|
1870
|
+
};
|
|
1871
|
+
|
|
1872
|
+
export default config;
|
|
1873
|
+
`;
|
|
1874
|
+
}
|
|
1875
|
+
/**
|
|
1876
|
+
* Generate .storybook/preview.ts configuration with viewport presets.
|
|
1877
|
+
*/
|
|
1878
|
+
generatePreviewConfig() {
|
|
1879
|
+
return `import type { Preview } from '@storybook/react';
|
|
1880
|
+
import '../theme/globals.css';
|
|
1881
|
+
|
|
1882
|
+
const customViewports = {
|
|
1883
|
+
mobile: {
|
|
1884
|
+
name: 'Mobile',
|
|
1885
|
+
styles: {
|
|
1886
|
+
width: '375px',
|
|
1887
|
+
height: '812px',
|
|
1888
|
+
},
|
|
1889
|
+
},
|
|
1890
|
+
tablet: {
|
|
1891
|
+
name: 'Tablet',
|
|
1892
|
+
styles: {
|
|
1893
|
+
width: '768px',
|
|
1894
|
+
height: '1024px',
|
|
1895
|
+
},
|
|
1896
|
+
},
|
|
1897
|
+
desktop: {
|
|
1898
|
+
name: 'Desktop',
|
|
1899
|
+
styles: {
|
|
1900
|
+
width: '1440px',
|
|
1901
|
+
height: '900px',
|
|
1902
|
+
},
|
|
1903
|
+
},
|
|
1904
|
+
};
|
|
1905
|
+
|
|
1906
|
+
const preview: Preview = {
|
|
1907
|
+
parameters: {
|
|
1908
|
+
viewport: {
|
|
1909
|
+
viewports: customViewports,
|
|
1910
|
+
},
|
|
1911
|
+
},
|
|
1912
|
+
};
|
|
1913
|
+
|
|
1914
|
+
export default preview;
|
|
1915
|
+
`;
|
|
1916
|
+
}
|
|
1917
|
+
};
|
|
1918
|
+
|
|
1919
|
+
// src/genome/genome-serializer.ts
|
|
1920
|
+
import Anthropic3 from "@anthropic-ai/sdk";
|
|
1921
|
+
import fs5 from "fs-extra";
|
|
1922
|
+
import path4 from "path";
|
|
1923
|
+
var GenomeSerializer = class {
|
|
1924
|
+
client;
|
|
1925
|
+
model;
|
|
1926
|
+
logger;
|
|
1927
|
+
constructor(options) {
|
|
1928
|
+
this.client = new Anthropic3({ apiKey: options.apiKey });
|
|
1929
|
+
this.model = options.model ?? "claude-sonnet-4-6-20250514";
|
|
1930
|
+
this.logger = options.logger.child({ prefix: "genome-serializer" });
|
|
1931
|
+
}
|
|
1932
|
+
// ─── Main Entry Point ────────────────────────────────────────
|
|
1933
|
+
/**
|
|
1934
|
+
* Serialize extraction results into the genome output format.
|
|
1935
|
+
*
|
|
1936
|
+
* Writes component .tsx files, theme files, genome.json, and README.md.
|
|
1937
|
+
*/
|
|
1938
|
+
async serialize(extractionResult, visionMap, components, outputDir, sourceUrl) {
|
|
1939
|
+
this.logger.info("Serializing genome output...");
|
|
1940
|
+
const componentsDir = path4.join(outputDir, "components");
|
|
1941
|
+
const themeDir = path4.join(outputDir, "theme");
|
|
1942
|
+
await fs5.ensureDir(componentsDir);
|
|
1943
|
+
await fs5.ensureDir(themeDir);
|
|
1944
|
+
await this.writeComponentFiles(components, componentsDir);
|
|
1945
|
+
await this.writeBarrelIndex(components, componentsDir);
|
|
1946
|
+
await this.writeThemeFiles(extractionResult, themeDir);
|
|
1947
|
+
const styleNarrative = await this.generateStyleNarrative(
|
|
1948
|
+
extractionResult,
|
|
1949
|
+
visionMap,
|
|
1950
|
+
sourceUrl
|
|
1951
|
+
);
|
|
1952
|
+
const colorRelationships = await this.analyzeColorRelationships(extractionResult);
|
|
1953
|
+
const genome = this.buildGenome(
|
|
1954
|
+
extractionResult,
|
|
1955
|
+
visionMap,
|
|
1956
|
+
components,
|
|
1957
|
+
colorRelationships,
|
|
1958
|
+
styleNarrative,
|
|
1959
|
+
sourceUrl
|
|
1960
|
+
);
|
|
1961
|
+
const genomePath = path4.join(outputDir, "genome.json");
|
|
1962
|
+
await fs5.writeJSON(genomePath, genome, { spaces: 2 });
|
|
1963
|
+
this.logger.info(`Wrote genome.json (${components.length} components)`);
|
|
1964
|
+
await this.writeReadme(genome, outputDir);
|
|
1965
|
+
return genome;
|
|
1966
|
+
}
|
|
1967
|
+
// ─── Component File Writing ──────────────────────────────────
|
|
1968
|
+
async writeComponentFiles(components, componentsDir) {
|
|
1969
|
+
for (const comp of components) {
|
|
1970
|
+
const filePath = path4.join(componentsDir, `${comp.name}.tsx`);
|
|
1971
|
+
await fs5.writeFile(filePath, comp.code, "utf-8");
|
|
1972
|
+
this.logger.debug(`Wrote component: ${comp.name}.tsx`);
|
|
1973
|
+
}
|
|
1974
|
+
this.logger.info(`Wrote ${components.length} component files`);
|
|
1975
|
+
}
|
|
1976
|
+
async writeBarrelIndex(components, componentsDir) {
|
|
1977
|
+
const lines = components.map(
|
|
1978
|
+
(comp) => `export { ${comp.name}, default as ${comp.name}Default } from './${comp.name}';`
|
|
1979
|
+
);
|
|
1980
|
+
const content = lines.join("\n") + "\n";
|
|
1981
|
+
await fs5.writeFile(path4.join(componentsDir, "index.ts"), content, "utf-8");
|
|
1982
|
+
this.logger.debug("Wrote components/index.ts barrel");
|
|
1983
|
+
}
|
|
1984
|
+
// ─── Theme File Generation ───────────────────────────────────
|
|
1985
|
+
/**
|
|
1986
|
+
* Write all theme output files:
|
|
1987
|
+
* - tailwind.config.ts: extends colors from palette, fontFamily from fontFamilies
|
|
1988
|
+
* - globals.css: @tailwind directives, CSS vars for colors, body font-family
|
|
1989
|
+
* - animations.ts: framer-motion preset objects
|
|
1990
|
+
* - tokens.json: DTCG format with color and typography sections
|
|
1991
|
+
*/
|
|
1992
|
+
async writeThemeFiles(extractionResult, themeDir) {
|
|
1993
|
+
await fs5.ensureDir(themeDir);
|
|
1994
|
+
await Promise.all([
|
|
1995
|
+
this.writeTailwindConfig(extractionResult, themeDir),
|
|
1996
|
+
this.writeGlobalsCss(extractionResult, themeDir),
|
|
1997
|
+
this.writeAnimations(themeDir),
|
|
1998
|
+
this.writeDesignTokens(extractionResult, themeDir)
|
|
1999
|
+
]);
|
|
2000
|
+
this.logger.info("Theme files written");
|
|
2001
|
+
}
|
|
2002
|
+
async writeTailwindConfig(result, themeDir) {
|
|
2003
|
+
const colorEntries = result.colors.palette.map((c) => {
|
|
2004
|
+
const key = c.name.replace(/\s+/g, "-").toLowerCase();
|
|
2005
|
+
return ` '${key}': '${c.value}',`;
|
|
2006
|
+
});
|
|
2007
|
+
const fontEntries = result.typography.fontFamilies.map((f) => {
|
|
2008
|
+
const key = f.category === "mono" ? "mono" : f.category === "serif" ? "serif" : "sans";
|
|
2009
|
+
const fallbacks = f.fallbacks.length > 0 ? f.fallbacks.map((fb) => `'${fb}'`).join(", ") : "'sans-serif'";
|
|
2010
|
+
return ` '${key}': ['${f.value}', ${fallbacks}],`;
|
|
2011
|
+
});
|
|
2012
|
+
const content = `import type { Config } from 'tailwindcss';
|
|
2013
|
+
|
|
2014
|
+
const config: Config = {
|
|
2015
|
+
content: ['./src/**/*.{ts,tsx,js,jsx}'],
|
|
2016
|
+
theme: {
|
|
2017
|
+
extend: {
|
|
2018
|
+
colors: {
|
|
2019
|
+
${colorEntries.join("\n")}
|
|
2020
|
+
},
|
|
2021
|
+
fontFamily: {
|
|
2022
|
+
${fontEntries.join("\n")}
|
|
2023
|
+
},
|
|
2024
|
+
},
|
|
2025
|
+
},
|
|
2026
|
+
plugins: [],
|
|
2027
|
+
};
|
|
2028
|
+
|
|
2029
|
+
export default config;
|
|
2030
|
+
`;
|
|
2031
|
+
await fs5.writeFile(path4.join(themeDir, "tailwind.config.ts"), content, "utf-8");
|
|
2032
|
+
this.logger.debug("Wrote theme/tailwind.config.ts");
|
|
2033
|
+
}
|
|
2034
|
+
async writeGlobalsCss(result, themeDir) {
|
|
2035
|
+
const colorVars = result.colors.palette.map((c) => {
|
|
2036
|
+
const key = c.name.replace(/\s+/g, "-").toLowerCase();
|
|
2037
|
+
return ` --color-${key}: ${c.value};`;
|
|
2038
|
+
});
|
|
2039
|
+
const primaryFont = result.typography.fontFamilies[0];
|
|
2040
|
+
const bodyFontFamily = primaryFont ? `${primaryFont.value}, ${primaryFont.fallbacks.join(", ") || "sans-serif"}` : "system-ui, sans-serif";
|
|
2041
|
+
const content = `@tailwind base;
|
|
2042
|
+
@tailwind components;
|
|
2043
|
+
@tailwind utilities;
|
|
2044
|
+
|
|
2045
|
+
:root {
|
|
2046
|
+
${colorVars.join("\n")}
|
|
2047
|
+
}
|
|
2048
|
+
|
|
2049
|
+
body {
|
|
2050
|
+
font-family: ${bodyFontFamily};
|
|
2051
|
+
-webkit-font-smoothing: antialiased;
|
|
2052
|
+
-moz-osx-font-smoothing: grayscale;
|
|
2053
|
+
}
|
|
2054
|
+
`;
|
|
2055
|
+
await fs5.writeFile(path4.join(themeDir, "globals.css"), content, "utf-8");
|
|
2056
|
+
this.logger.debug("Wrote theme/globals.css");
|
|
2057
|
+
}
|
|
2058
|
+
async writeAnimations(themeDir) {
|
|
2059
|
+
const content = `/**
|
|
2060
|
+
* Motion presets extracted from source site.
|
|
2061
|
+
* Use with framer-motion or CSS transitions.
|
|
2062
|
+
*/
|
|
2063
|
+
|
|
2064
|
+
export const motionPresets = {
|
|
2065
|
+
fadeUp: {
|
|
2066
|
+
initial: { opacity: 0, y: 20 },
|
|
2067
|
+
animate: { opacity: 1, y: 0 },
|
|
2068
|
+
transition: { duration: 0.5, ease: 'easeOut' },
|
|
2069
|
+
},
|
|
2070
|
+
fadeIn: {
|
|
2071
|
+
initial: { opacity: 0 },
|
|
2072
|
+
animate: { opacity: 1 },
|
|
2073
|
+
transition: { duration: 0.4, ease: 'easeOut' },
|
|
2074
|
+
},
|
|
2075
|
+
slideInLeft: {
|
|
2076
|
+
initial: { opacity: 0, x: -30 },
|
|
2077
|
+
animate: { opacity: 1, x: 0 },
|
|
2078
|
+
transition: { duration: 0.5, ease: 'easeOut' },
|
|
2079
|
+
},
|
|
2080
|
+
slideInRight: {
|
|
2081
|
+
initial: { opacity: 0, x: 30 },
|
|
2082
|
+
animate: { opacity: 1, x: 0 },
|
|
2083
|
+
transition: { duration: 0.5, ease: 'easeOut' },
|
|
2084
|
+
},
|
|
2085
|
+
scaleIn: {
|
|
2086
|
+
initial: { opacity: 0, scale: 0.9 },
|
|
2087
|
+
animate: { opacity: 1, scale: 1 },
|
|
2088
|
+
transition: { duration: 0.4, ease: 'easeOut' },
|
|
2089
|
+
},
|
|
2090
|
+
staggerContainer: {
|
|
2091
|
+
animate: {
|
|
2092
|
+
transition: {
|
|
2093
|
+
staggerChildren: 0.08,
|
|
2094
|
+
delayChildren: 0.1,
|
|
2095
|
+
},
|
|
2096
|
+
},
|
|
2097
|
+
},
|
|
2098
|
+
} as const;
|
|
2099
|
+
|
|
2100
|
+
export type MotionPresetKey = keyof typeof motionPresets;
|
|
2101
|
+
`;
|
|
2102
|
+
await fs5.writeFile(path4.join(themeDir, "animations.ts"), content, "utf-8");
|
|
2103
|
+
this.logger.debug("Wrote theme/animations.ts");
|
|
2104
|
+
}
|
|
2105
|
+
async writeDesignTokens(result, themeDir) {
|
|
2106
|
+
const colorTokens = {};
|
|
2107
|
+
for (const c of result.colors.palette) {
|
|
2108
|
+
const key = c.name.replace(/\s+/g, "-").toLowerCase();
|
|
2109
|
+
colorTokens[key] = {
|
|
2110
|
+
$type: "color",
|
|
2111
|
+
$value: c.value,
|
|
2112
|
+
$description: `Usage: ${c.usage.join(", ")}`
|
|
2113
|
+
};
|
|
2114
|
+
}
|
|
2115
|
+
const typographyTokens = {};
|
|
2116
|
+
for (const size of result.typography.fontSizes) {
|
|
2117
|
+
typographyTokens[size.name] = {
|
|
2118
|
+
$type: "dimension",
|
|
2119
|
+
$value: size.value,
|
|
2120
|
+
$description: `${size.pxValue}px / ${size.remValue}rem`
|
|
2121
|
+
};
|
|
2122
|
+
}
|
|
2123
|
+
for (const family of result.typography.fontFamilies) {
|
|
2124
|
+
typographyTokens[`family-${family.category}`] = {
|
|
2125
|
+
$type: "fontFamily",
|
|
2126
|
+
$value: [family.value, ...family.fallbacks]
|
|
2127
|
+
};
|
|
2128
|
+
}
|
|
2129
|
+
const tokens = {
|
|
2130
|
+
$schema: "https://design-tokens.github.io/community-group/format/",
|
|
2131
|
+
color: colorTokens,
|
|
2132
|
+
typography: typographyTokens
|
|
2133
|
+
};
|
|
2134
|
+
await fs5.writeJSON(path4.join(themeDir, "tokens.json"), tokens, { spaces: 2 });
|
|
2135
|
+
this.logger.debug("Wrote theme/tokens.json");
|
|
2136
|
+
}
|
|
2137
|
+
// ─── Genome Assembly ─────────────────────────────────────────
|
|
2138
|
+
buildGenome(result, visionMap, components, colorRelationships, styleNarrative, sourceUrl) {
|
|
2139
|
+
const colors = {};
|
|
2140
|
+
for (const c of result.colors.palette) {
|
|
2141
|
+
const key = c.name.replace(/\s+/g, "-").toLowerCase();
|
|
2142
|
+
colors[key] = c.value;
|
|
2143
|
+
}
|
|
2144
|
+
const typographyScale = {};
|
|
2145
|
+
for (const size of result.typography.fontSizes) {
|
|
2146
|
+
typographyScale[size.name] = size.value;
|
|
2147
|
+
}
|
|
2148
|
+
const scaleRatio = this.detectScaleRatio(
|
|
2149
|
+
result.typography.fontSizes.map((s) => s.pxValue)
|
|
2150
|
+
);
|
|
2151
|
+
const families = {};
|
|
2152
|
+
for (const f of result.typography.fontFamilies) {
|
|
2153
|
+
families[f.category] = f.value;
|
|
2154
|
+
}
|
|
2155
|
+
const spacingScale = {};
|
|
2156
|
+
for (const s of result.spacing.scale) {
|
|
2157
|
+
spacingScale[s.name] = s.value;
|
|
2158
|
+
}
|
|
2159
|
+
const shadows = {};
|
|
2160
|
+
for (const s of result.effects.shadows) {
|
|
2161
|
+
shadows[s.name] = s.value;
|
|
2162
|
+
}
|
|
2163
|
+
const radii = {};
|
|
2164
|
+
for (const r of result.effects.borderRadii) {
|
|
2165
|
+
radii[r.name] = r.value;
|
|
2166
|
+
}
|
|
2167
|
+
const transitions = {};
|
|
2168
|
+
for (const t of result.animations.transitions) {
|
|
2169
|
+
const key = t.property.replace(/\s+/g, "-");
|
|
2170
|
+
transitions[key] = `${t.duration} ${t.timingFunction}${t.delay ? ` ${t.delay}` : ""}`;
|
|
2171
|
+
}
|
|
2172
|
+
const genomeComponents = components.map((comp) => ({
|
|
2173
|
+
name: comp.name,
|
|
2174
|
+
type: comp.type,
|
|
2175
|
+
file: `components/${comp.name}.tsx`,
|
|
2176
|
+
storyFile: `stories/${comp.name}.stories.tsx`,
|
|
2177
|
+
description: this.extractComponentDescription(comp),
|
|
2178
|
+
designRelationships: comp.designRelationships,
|
|
2179
|
+
propsSchema: this.extractPropsSchema(comp.propsInterface),
|
|
2180
|
+
defaultContent: comp.defaultProps
|
|
2181
|
+
}));
|
|
2182
|
+
const sectionOrder = visionMap.sections.map((s) => s.name);
|
|
2183
|
+
const gridSystem = this.detectGridSystem(result);
|
|
2184
|
+
const breakpoints = {};
|
|
2185
|
+
for (const bp of result.layout.breakpoints) {
|
|
2186
|
+
if (bp.minWidth) {
|
|
2187
|
+
breakpoints[bp.name] = parseInt(bp.minWidth, 10);
|
|
2188
|
+
}
|
|
2189
|
+
}
|
|
2190
|
+
if (Object.keys(breakpoints).length === 0) {
|
|
2191
|
+
Object.assign(breakpoints, { sm: 640, md: 768, lg: 1024, xl: 1280, "2xl": 1536 });
|
|
2192
|
+
}
|
|
2193
|
+
const patterns = this.detectPatterns(result);
|
|
2194
|
+
const spacingRhythm = visionMap.designLanguage.spacingRhythm || "consistent";
|
|
2195
|
+
return {
|
|
2196
|
+
meta: {
|
|
2197
|
+
sourceUrl,
|
|
2198
|
+
extractedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2199
|
+
extraktorVersion: "1.0.0",
|
|
2200
|
+
componentCount: components.length,
|
|
2201
|
+
pageTitle: result.metadata.title || ""
|
|
2202
|
+
},
|
|
2203
|
+
theme: {
|
|
2204
|
+
colors,
|
|
2205
|
+
colorRelationships,
|
|
2206
|
+
typography: {
|
|
2207
|
+
scale: typographyScale,
|
|
2208
|
+
scaleRatio,
|
|
2209
|
+
families
|
|
2210
|
+
},
|
|
2211
|
+
spacing: {
|
|
2212
|
+
scale: spacingScale,
|
|
2213
|
+
rhythm: spacingRhythm
|
|
2214
|
+
},
|
|
2215
|
+
effects: {
|
|
2216
|
+
shadows,
|
|
2217
|
+
radii,
|
|
2218
|
+
transitions
|
|
2219
|
+
},
|
|
2220
|
+
animations: {
|
|
2221
|
+
entrance: visionMap.designLanguage.animationStyle || "fade-up",
|
|
2222
|
+
interaction: "hover-scale",
|
|
2223
|
+
motionPresets: {
|
|
2224
|
+
fadeUp: { initial: { opacity: 0, y: 20 }, animate: { opacity: 1, y: 0 }, transition: { duration: 0.5 } },
|
|
2225
|
+
fadeIn: { initial: { opacity: 0 }, animate: { opacity: 1 }, transition: { duration: 0.4 } },
|
|
2226
|
+
slideInLeft: { initial: { opacity: 0, x: -30 }, animate: { opacity: 1, x: 0 }, transition: { duration: 0.5 } },
|
|
2227
|
+
slideInRight: { initial: { opacity: 0, x: 30 }, animate: { opacity: 1, x: 0 }, transition: { duration: 0.5 } },
|
|
2228
|
+
scaleIn: { initial: { opacity: 0, scale: 0.9 }, animate: { opacity: 1, scale: 1 }, transition: { duration: 0.4 } },
|
|
2229
|
+
staggerContainer: { animate: { transition: { staggerChildren: 0.08 } } }
|
|
2230
|
+
}
|
|
2231
|
+
}
|
|
2232
|
+
},
|
|
2233
|
+
components: genomeComponents,
|
|
2234
|
+
layout: {
|
|
2235
|
+
sectionOrder,
|
|
2236
|
+
gridSystem,
|
|
2237
|
+
breakpoints,
|
|
2238
|
+
patterns
|
|
2239
|
+
},
|
|
2240
|
+
styleNarrative
|
|
2241
|
+
};
|
|
2242
|
+
}
|
|
2243
|
+
// ─── Claude AI Helpers ────────────────────────────────────────
|
|
2244
|
+
/**
|
|
2245
|
+
* Generate a 2-3 paragraph style narrative via Claude describing the design
|
|
2246
|
+
* principles in second person, suitable for guiding future page generation.
|
|
2247
|
+
*/
|
|
2248
|
+
async generateStyleNarrative(result, visionMap, sourceUrl) {
|
|
2249
|
+
this.logger.info("Generating style narrative via Claude...");
|
|
2250
|
+
const colorSummary = result.colors.palette.slice(0, 8).map((c) => `${c.name}: ${c.value}`).join(", ");
|
|
2251
|
+
const fontSummary = result.typography.fontFamilies.map((f) => `${f.value} (${f.category})`).join(", ");
|
|
2252
|
+
const designLang = visionMap.designLanguage;
|
|
2253
|
+
const prompt = `You are a design system analyst. Based on the following extracted design data from ${sourceUrl}, write a 2-3 paragraph style narrative in second person ("you") that describes the design principles, visual identity, and how to maintain consistency when building new pages.
|
|
2254
|
+
|
|
2255
|
+
Design Language:
|
|
2256
|
+
- Color Strategy: ${designLang.colorStrategy}
|
|
2257
|
+
- Typography Hierarchy: ${designLang.typographyHierarchy}
|
|
2258
|
+
- Spacing Rhythm: ${designLang.spacingRhythm}
|
|
2259
|
+
- Animation Style: ${designLang.animationStyle}
|
|
2260
|
+
- Overall Aesthetic: ${designLang.overallAesthetic}
|
|
2261
|
+
|
|
2262
|
+
Colors: ${colorSummary}
|
|
2263
|
+
Fonts: ${fontSummary}
|
|
2264
|
+
Sections: ${visionMap.sections.map((s) => s.name).join(", ")}
|
|
2265
|
+
|
|
2266
|
+
Write a concise, actionable narrative. Focus on the "why" behind design choices and how to maintain the visual identity. Do not use bullet points - write flowing paragraphs.`;
|
|
2267
|
+
try {
|
|
2268
|
+
const response = await this.client.messages.create({
|
|
2269
|
+
model: this.model,
|
|
2270
|
+
max_tokens: 1024,
|
|
2271
|
+
messages: [{ role: "user", content: prompt }]
|
|
2272
|
+
});
|
|
2273
|
+
const textBlock = response.content.find((b) => b.type === "text");
|
|
2274
|
+
return textBlock?.text ?? "";
|
|
2275
|
+
} catch (error) {
|
|
2276
|
+
this.logger.warn("Failed to generate style narrative, using fallback", error);
|
|
2277
|
+
return `This design system follows a ${designLang.overallAesthetic} aesthetic with ${designLang.colorStrategy} color usage and ${designLang.typographyHierarchy} typography. Maintain the ${designLang.spacingRhythm} spacing rhythm when extending the design.`;
|
|
2278
|
+
}
|
|
2279
|
+
}
|
|
2280
|
+
/**
|
|
2281
|
+
* Analyze color relationships via Claude, returning a JSON array of
|
|
2282
|
+
* token/usage/pairedWith/neverWith objects.
|
|
2283
|
+
*/
|
|
2284
|
+
async analyzeColorRelationships(result) {
|
|
2285
|
+
this.logger.info("Analyzing color relationships via Claude...");
|
|
2286
|
+
const palette = result.colors.palette.slice(0, 12);
|
|
2287
|
+
if (palette.length === 0) {
|
|
2288
|
+
return [];
|
|
2289
|
+
}
|
|
2290
|
+
const paletteDescription = palette.map((c) => `${c.name} (${c.value}): used for ${c.usage.join(", ")}, frequency ${c.frequency}`).join("\n");
|
|
2291
|
+
const prompt = `Analyze the following color palette and return a JSON array of color relationships. Each entry should describe how a color token is used, which colors it pairs well with, and which colors it should never be combined with.
|
|
2292
|
+
|
|
2293
|
+
Palette:
|
|
2294
|
+
${paletteDescription}
|
|
2295
|
+
|
|
2296
|
+
Return ONLY a valid JSON array with this exact shape (no markdown fencing, no explanation):
|
|
2297
|
+
[
|
|
2298
|
+
{
|
|
2299
|
+
"token": "color-name",
|
|
2300
|
+
"usage": "brief description of primary usage",
|
|
2301
|
+
"pairedWith": ["other-color-1", "other-color-2"],
|
|
2302
|
+
"neverWith": ["clash-color"]
|
|
2303
|
+
}
|
|
2304
|
+
]
|
|
2305
|
+
|
|
2306
|
+
Include an entry for each color in the palette.`;
|
|
2307
|
+
try {
|
|
2308
|
+
const response = await this.client.messages.create({
|
|
2309
|
+
model: this.model,
|
|
2310
|
+
max_tokens: 2048,
|
|
2311
|
+
messages: [{ role: "user", content: prompt }]
|
|
2312
|
+
});
|
|
2313
|
+
const textBlock = response.content.find((b) => b.type === "text");
|
|
2314
|
+
const raw = textBlock?.text ?? "[]";
|
|
2315
|
+
const cleaned = raw.replace(/```json\s*/g, "").replace(/```\s*/g, "").trim();
|
|
2316
|
+
const parsed = JSON.parse(cleaned);
|
|
2317
|
+
if (!Array.isArray(parsed)) return [];
|
|
2318
|
+
return parsed.filter(
|
|
2319
|
+
(r) => typeof r.token === "string" && typeof r.usage === "string" && Array.isArray(r.pairedWith) && Array.isArray(r.neverWith)
|
|
2320
|
+
);
|
|
2321
|
+
} catch (error) {
|
|
2322
|
+
this.logger.warn("Failed to analyze color relationships, using fallback", error);
|
|
2323
|
+
return palette.map((c) => ({
|
|
2324
|
+
token: c.name,
|
|
2325
|
+
usage: c.usage.join(", "),
|
|
2326
|
+
pairedWith: [],
|
|
2327
|
+
neverWith: []
|
|
2328
|
+
}));
|
|
2329
|
+
}
|
|
2330
|
+
}
|
|
2331
|
+
// ─── README Generation ────────────────────────────────────────
|
|
2332
|
+
async writeReadme(genome, outputDir) {
|
|
2333
|
+
const componentList = genome.components.map((c) => `- **${c.name}** (\`${c.type}\`): ${c.description}`).join("\n");
|
|
2334
|
+
const colorList = Object.entries(genome.theme.colors).map(([name, value]) => `| \`${name}\` | \`${value}\` |`).join("\n");
|
|
2335
|
+
const content = `# ${genome.meta.pageTitle || "Design System"} - Design Genome
|
|
2336
|
+
|
|
2337
|
+
> Extracted from [${genome.meta.sourceUrl}](${genome.meta.sourceUrl}) on ${genome.meta.extractedAt}
|
|
2338
|
+
> Generated by extraktor v${genome.meta.extraktorVersion}
|
|
2339
|
+
|
|
2340
|
+
## Style Narrative
|
|
2341
|
+
|
|
2342
|
+
${genome.styleNarrative}
|
|
2343
|
+
|
|
2344
|
+
## Components (${genome.meta.componentCount})
|
|
2345
|
+
|
|
2346
|
+
${componentList}
|
|
2347
|
+
|
|
2348
|
+
### Usage
|
|
2349
|
+
|
|
2350
|
+
\`\`\`tsx
|
|
2351
|
+
import { ${genome.components.map((c) => c.name).join(", ")} } from './components';
|
|
2352
|
+
\`\`\`
|
|
2353
|
+
|
|
2354
|
+
### Run Storybook
|
|
2355
|
+
|
|
2356
|
+
\`\`\`bash
|
|
2357
|
+
cd stories && npx storybook dev -p 6006
|
|
2358
|
+
\`\`\`
|
|
2359
|
+
|
|
2360
|
+
### Regenerate a new page
|
|
2361
|
+
|
|
2362
|
+
\`\`\`bash
|
|
2363
|
+
extraktor regen ./ --prompt "Your page description" -o ../my-new-site/
|
|
2364
|
+
\`\`\`
|
|
2365
|
+
|
|
2366
|
+
## Color Palette
|
|
2367
|
+
|
|
2368
|
+
| Token | Value |
|
|
2369
|
+
|-------|-------|
|
|
2370
|
+
${colorList}
|
|
2371
|
+
|
|
2372
|
+
## Typography
|
|
2373
|
+
|
|
2374
|
+
- **Scale ratio:** ${genome.theme.typography.scaleRatio.toFixed(3)}
|
|
2375
|
+
- **Families:** ${Object.entries(genome.theme.typography.families).map(([k, v]) => `${k}: ${v}`).join(", ")}
|
|
2376
|
+
|
|
2377
|
+
## Layout
|
|
2378
|
+
|
|
2379
|
+
- **Section order:** ${genome.layout.sectionOrder.join(" > ")}
|
|
2380
|
+
- **Grid system:** ${genome.layout.gridSystem}
|
|
2381
|
+
- **Patterns:** ${genome.layout.patterns.join(", ") || "none detected"}
|
|
2382
|
+
|
|
2383
|
+
## Files
|
|
2384
|
+
|
|
2385
|
+
\`\`\`
|
|
2386
|
+
genome.json # Complete design genome
|
|
2387
|
+
components/ # React/TSX component files
|
|
2388
|
+
components/index.ts # Barrel exports
|
|
2389
|
+
theme/
|
|
2390
|
+
tailwind.config.ts # Tailwind CSS configuration
|
|
2391
|
+
globals.css # CSS variables and base styles
|
|
2392
|
+
animations.ts # Framer Motion presets
|
|
2393
|
+
tokens.json # DTCG design tokens
|
|
2394
|
+
stories/ # Storybook stories
|
|
2395
|
+
screenshots/ # Vision analysis screenshots
|
|
2396
|
+
\`\`\`
|
|
2397
|
+
`;
|
|
2398
|
+
await fs5.writeFile(path4.join(outputDir, "README.md"), content, "utf-8");
|
|
2399
|
+
this.logger.debug("Wrote README.md");
|
|
2400
|
+
}
|
|
2401
|
+
// ─── Utility Helpers ──────────────────────────────────────────
|
|
2402
|
+
/**
|
|
2403
|
+
* Detect the typographic scale ratio from sorted font sizes.
|
|
2404
|
+
* Returns the geometric mean of consecutive size ratios, clamped to [1.05, 2.0].
|
|
2405
|
+
*/
|
|
2406
|
+
detectScaleRatio(sizes) {
|
|
2407
|
+
if (sizes.length < 2) return 1.25;
|
|
2408
|
+
const sorted = [...sizes].sort((a, b) => a - b);
|
|
2409
|
+
const unique = [...new Set(sorted)].filter((s) => s > 0);
|
|
2410
|
+
if (unique.length < 2) return 1.25;
|
|
2411
|
+
const ratios = [];
|
|
2412
|
+
for (let i = 1; i < unique.length; i++) {
|
|
2413
|
+
ratios.push(unique[i] / unique[i - 1]);
|
|
2414
|
+
}
|
|
2415
|
+
const product = ratios.reduce((acc, r) => acc * r, 1);
|
|
2416
|
+
const geoMean = Math.pow(product, 1 / ratios.length);
|
|
2417
|
+
return Math.max(1.05, Math.min(geoMean, 2));
|
|
2418
|
+
}
|
|
2419
|
+
/**
|
|
2420
|
+
* Parse a TypeScript interface string into a Record of prop name to type.
|
|
2421
|
+
* Handles patterns like `propName: type;` and `propName?: type;`.
|
|
2422
|
+
*/
|
|
2423
|
+
extractPropsSchema(propsInterface) {
|
|
2424
|
+
const schema = {};
|
|
2425
|
+
if (!propsInterface) return schema;
|
|
2426
|
+
const propRegex = /(\w+)\??\s*:\s*([^;}\n]+)/g;
|
|
2427
|
+
let match;
|
|
2428
|
+
while ((match = propRegex.exec(propsInterface)) !== null) {
|
|
2429
|
+
const name = match[1].trim();
|
|
2430
|
+
const type = match[2].trim();
|
|
2431
|
+
schema[name] = type;
|
|
2432
|
+
}
|
|
2433
|
+
return schema;
|
|
2434
|
+
}
|
|
2435
|
+
/**
|
|
2436
|
+
* Detect the grid system from layout extraction data.
|
|
2437
|
+
* Inspects grid patterns, flex patterns, and containers in priority order.
|
|
2438
|
+
*/
|
|
2439
|
+
detectGridSystem(result) {
|
|
2440
|
+
const { gridPatterns, flexPatterns, containers } = result.layout;
|
|
2441
|
+
if (gridPatterns.length > 0) {
|
|
2442
|
+
const colCounts = gridPatterns.map((g) => g.columns).filter(Boolean);
|
|
2443
|
+
if (colCounts.length > 0) {
|
|
2444
|
+
return `css-grid (${colCounts[0]})`;
|
|
2445
|
+
}
|
|
2446
|
+
return "css-grid";
|
|
2447
|
+
}
|
|
2448
|
+
if (flexPatterns.length > 0) {
|
|
2449
|
+
return "flexbox";
|
|
2450
|
+
}
|
|
2451
|
+
if (containers.length > 0) {
|
|
2452
|
+
const maxWidth = containers[0].maxWidth;
|
|
2453
|
+
return `container-based (${maxWidth})`;
|
|
2454
|
+
}
|
|
2455
|
+
return "12-col, 1280px max";
|
|
2456
|
+
}
|
|
2457
|
+
/**
|
|
2458
|
+
* Detect layout patterns from extraction and layout architecture data.
|
|
2459
|
+
*/
|
|
2460
|
+
detectPatterns(result) {
|
|
2461
|
+
const patterns = [];
|
|
2462
|
+
if (result.layout.gridPatterns.length > 0) {
|
|
2463
|
+
patterns.push("css-grid");
|
|
2464
|
+
}
|
|
2465
|
+
if (result.layout.flexPatterns.length > 0) {
|
|
2466
|
+
patterns.push("flexbox");
|
|
2467
|
+
}
|
|
2468
|
+
if (result.layout.containers.length > 0) {
|
|
2469
|
+
patterns.push("container-layout");
|
|
2470
|
+
}
|
|
2471
|
+
if (result.layout.zIndexLayers.length > 3) {
|
|
2472
|
+
patterns.push("layered-z-index");
|
|
2473
|
+
}
|
|
2474
|
+
const stickyElements = result.rawStyles.filter(
|
|
2475
|
+
(el) => el.computedStyles.position === "sticky" || el.computedStyles.position === "fixed"
|
|
2476
|
+
);
|
|
2477
|
+
if (stickyElements.length > 0) {
|
|
2478
|
+
patterns.push("sticky-elements");
|
|
2479
|
+
}
|
|
2480
|
+
if (result.layout.breakpoints.length > 0) {
|
|
2481
|
+
patterns.push("responsive");
|
|
2482
|
+
}
|
|
2483
|
+
const layoutArch = result.layoutArchitecture;
|
|
2484
|
+
if (layoutArch?.layoutType) {
|
|
2485
|
+
patterns.push(layoutArch.layoutType);
|
|
2486
|
+
}
|
|
2487
|
+
return patterns;
|
|
2488
|
+
}
|
|
2489
|
+
/**
|
|
2490
|
+
* Extract a short description from a synthesized component.
|
|
2491
|
+
* Attempts to pull from JSDoc comment, then falls back to type name.
|
|
2492
|
+
*/
|
|
2493
|
+
extractComponentDescription(comp) {
|
|
2494
|
+
if (comp.defaultProps._description && typeof comp.defaultProps._description === "string") {
|
|
2495
|
+
return comp.defaultProps._description;
|
|
2496
|
+
}
|
|
2497
|
+
const jsdocMatch = comp.code.match(/\/\*\*\s*\n?\s*\*\s*(.+?)(?:\n|\*\/)/);
|
|
2498
|
+
if (jsdocMatch) {
|
|
2499
|
+
return jsdocMatch[1].trim();
|
|
2500
|
+
}
|
|
2501
|
+
return `${comp.type.charAt(0).toUpperCase() + comp.type.slice(1)} component`;
|
|
2502
|
+
}
|
|
2503
|
+
};
|
|
2504
|
+
|
|
2505
|
+
// src/genome/page-regenerator.ts
|
|
2506
|
+
import Anthropic4 from "@anthropic-ai/sdk";
|
|
2507
|
+
import fs6 from "fs-extra";
|
|
2508
|
+
import path5 from "path";
|
|
2509
|
+
var PageRegenerator = class {
|
|
2510
|
+
client;
|
|
2511
|
+
model;
|
|
2512
|
+
logger;
|
|
2513
|
+
constructor(options) {
|
|
2514
|
+
this.client = new Anthropic4({ apiKey: options.apiKey });
|
|
2515
|
+
this.model = options.model || "claude-sonnet-4-6-20250514";
|
|
2516
|
+
this.logger = options.logger;
|
|
2517
|
+
}
|
|
2518
|
+
/**
|
|
2519
|
+
* Regenerate a new page from genome + prompt
|
|
2520
|
+
*/
|
|
2521
|
+
async regenerate(genome, genomeDir, prompt, outputDir, sections) {
|
|
2522
|
+
await fs6.ensureDir(outputDir);
|
|
2523
|
+
this.logger.info("Copying genome components...");
|
|
2524
|
+
const srcComponentsDir = path5.join(genomeDir, "components");
|
|
2525
|
+
const destComponentsDir = path5.join(outputDir, "components");
|
|
2526
|
+
if (await fs6.pathExists(srcComponentsDir)) {
|
|
2527
|
+
await fs6.copy(srcComponentsDir, destComponentsDir);
|
|
2528
|
+
}
|
|
2529
|
+
const srcThemeDir = path5.join(genomeDir, "theme");
|
|
2530
|
+
if (await fs6.pathExists(srcThemeDir)) {
|
|
2531
|
+
await fs6.copy(
|
|
2532
|
+
path5.join(srcThemeDir, "tailwind.config.ts"),
|
|
2533
|
+
path5.join(outputDir, "tailwind.config.ts")
|
|
2534
|
+
);
|
|
2535
|
+
const srcDir = path5.join(outputDir, "src", "app");
|
|
2536
|
+
await fs6.ensureDir(srcDir);
|
|
2537
|
+
if (await fs6.pathExists(path5.join(srcThemeDir, "globals.css"))) {
|
|
2538
|
+
await fs6.copy(
|
|
2539
|
+
path5.join(srcThemeDir, "globals.css"),
|
|
2540
|
+
path5.join(srcDir, "globals.css")
|
|
2541
|
+
);
|
|
2542
|
+
}
|
|
2543
|
+
}
|
|
2544
|
+
this.logger.info("Generating page composition...");
|
|
2545
|
+
const pageCode = await this.generatePage(genome, prompt, sections);
|
|
2546
|
+
this.logger.info("Writing Next.js project...");
|
|
2547
|
+
await this.writeProject(outputDir, pageCode, genome);
|
|
2548
|
+
this.logger.info(`Regen complete: ${outputDir}`);
|
|
2549
|
+
return outputDir;
|
|
2550
|
+
}
|
|
2551
|
+
async generatePage(genome, prompt, sections) {
|
|
2552
|
+
const componentInventory = genome.components.map((c) => {
|
|
2553
|
+
const props = Object.entries(c.propsSchema).map(([k, v]) => ` ${k}: ${v}`).join("\n");
|
|
2554
|
+
return `### ${c.name} (${c.type})
|
|
2555
|
+
${c.description}
|
|
2556
|
+
Props:
|
|
2557
|
+
${props || " className?: string"}`;
|
|
2558
|
+
}).join("\n\n");
|
|
2559
|
+
const systemPrompt = `You are a senior frontend developer. You have a complete design system (genome) extracted from ${genome.meta.sourceUrl}. Create a NEW page using ONLY the components and design tokens from this system, with completely original content.
|
|
2560
|
+
|
|
2561
|
+
STYLE NARRATIVE:
|
|
2562
|
+
${genome.styleNarrative}
|
|
2563
|
+
|
|
2564
|
+
AVAILABLE COMPONENTS:
|
|
2565
|
+
${componentInventory}
|
|
2566
|
+
|
|
2567
|
+
SECTION ORDER FROM ORIGINAL:
|
|
2568
|
+
${genome.layout.sectionOrder.join(" -> ")}
|
|
2569
|
+
|
|
2570
|
+
LAYOUT PATTERNS: ${genome.layout.patterns.join(", ")}
|
|
2571
|
+
|
|
2572
|
+
RULES:
|
|
2573
|
+
1. Use ONLY components imported from '../components'
|
|
2574
|
+
2. Follow the color/typography/spacing relationships from the genome
|
|
2575
|
+
3. Match the animation style described in the narrative
|
|
2576
|
+
4. Content must be original, specific, and relevant to the user's prompt
|
|
2577
|
+
5. Output a complete Next.js page.tsx with 'use client' directive
|
|
2578
|
+
6. Import framer-motion for page-level animations
|
|
2579
|
+
7. Use realistic, specific content (not lorem ipsum or generic placeholder)
|
|
2580
|
+
8. Return ONLY the code, no markdown blocks`;
|
|
2581
|
+
const userPrompt = sections ? `Create a page with these sections: ${sections.join(", ")}. Theme: ${prompt}` : `Create a complete landing page: ${prompt}`;
|
|
2582
|
+
const response = await this.client.messages.create({
|
|
2583
|
+
model: this.model,
|
|
2584
|
+
max_tokens: 8192,
|
|
2585
|
+
system: systemPrompt,
|
|
2586
|
+
messages: [{ role: "user", content: userPrompt }]
|
|
2587
|
+
});
|
|
2588
|
+
const code = response.content[0]?.type === "text" ? response.content[0].text : "";
|
|
2589
|
+
return code.replace(/^```(?:tsx?|typescript)?\n?/gm, "").replace(/```$/gm, "").trim();
|
|
2590
|
+
}
|
|
2591
|
+
async writeProject(outputDir, pageCode, genome) {
|
|
2592
|
+
const srcApp = path5.join(outputDir, "src", "app");
|
|
2593
|
+
await fs6.ensureDir(srcApp);
|
|
2594
|
+
await fs6.writeFile(path5.join(srcApp, "page.tsx"), pageCode);
|
|
2595
|
+
const escapedTitle = (genome.meta.pageTitle || "Generated Page").replace(/'/g, "\\'");
|
|
2596
|
+
const layoutCode = `import type { Metadata } from 'next';
|
|
2597
|
+
import './globals.css';
|
|
2598
|
+
|
|
2599
|
+
export const metadata: Metadata = {
|
|
2600
|
+
title: '${escapedTitle}',
|
|
2601
|
+
description: 'Generated with extraktor genome',
|
|
2602
|
+
};
|
|
2603
|
+
|
|
2604
|
+
export default function RootLayout({
|
|
2605
|
+
children,
|
|
2606
|
+
}: {
|
|
2607
|
+
children: React.ReactNode;
|
|
2608
|
+
}) {
|
|
2609
|
+
return (
|
|
2610
|
+
<html lang="en">
|
|
2611
|
+
<body>{children}</body>
|
|
2612
|
+
</html>
|
|
2613
|
+
);
|
|
2614
|
+
}
|
|
2615
|
+
`;
|
|
2616
|
+
await fs6.writeFile(path5.join(srcApp, "layout.tsx"), layoutCode);
|
|
2617
|
+
const packageJson = {
|
|
2618
|
+
name: path5.basename(outputDir),
|
|
2619
|
+
version: "0.1.0",
|
|
2620
|
+
private: true,
|
|
2621
|
+
scripts: {
|
|
2622
|
+
dev: "next dev",
|
|
2623
|
+
build: "next build",
|
|
2624
|
+
start: "next start"
|
|
2625
|
+
},
|
|
2626
|
+
dependencies: {
|
|
2627
|
+
next: "^15.0.0",
|
|
2628
|
+
react: "^19.0.0",
|
|
2629
|
+
"react-dom": "^19.0.0",
|
|
2630
|
+
"framer-motion": "^11.0.0"
|
|
2631
|
+
},
|
|
2632
|
+
devDependencies: {
|
|
2633
|
+
typescript: "^5.0.0",
|
|
2634
|
+
"@types/react": "^19.0.0",
|
|
2635
|
+
"@types/node": "^22.0.0",
|
|
2636
|
+
tailwindcss: "^4.0.0",
|
|
2637
|
+
postcss: "^8.0.0"
|
|
2638
|
+
}
|
|
2639
|
+
};
|
|
2640
|
+
await fs6.writeJSON(path5.join(outputDir, "package.json"), packageJson, { spaces: 2 });
|
|
2641
|
+
const tsConfig = {
|
|
2642
|
+
compilerOptions: {
|
|
2643
|
+
target: "ES2017",
|
|
2644
|
+
lib: ["dom", "dom.iterable", "esnext"],
|
|
2645
|
+
allowJs: true,
|
|
2646
|
+
skipLibCheck: true,
|
|
2647
|
+
strict: true,
|
|
2648
|
+
noEmit: true,
|
|
2649
|
+
esModuleInterop: true,
|
|
2650
|
+
module: "esnext",
|
|
2651
|
+
moduleResolution: "bundler",
|
|
2652
|
+
resolveJsonModule: true,
|
|
2653
|
+
isolatedModules: true,
|
|
2654
|
+
jsx: "preserve",
|
|
2655
|
+
incremental: true,
|
|
2656
|
+
plugins: [{ name: "next" }],
|
|
2657
|
+
paths: { "@/*": ["./src/*"] }
|
|
2658
|
+
},
|
|
2659
|
+
include: ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
|
2660
|
+
exclude: ["node_modules"]
|
|
2661
|
+
};
|
|
2662
|
+
await fs6.writeJSON(path5.join(outputDir, "tsconfig.json"), tsConfig, { spaces: 2 });
|
|
2663
|
+
}
|
|
2664
|
+
};
|
|
2665
|
+
|
|
2666
|
+
// src/genome/genome-engine.ts
|
|
2667
|
+
var GenomeEngine = class {
|
|
2668
|
+
logger;
|
|
2669
|
+
constructor(options) {
|
|
2670
|
+
this.logger = options?.logger || createLogger({ level: "info" });
|
|
2671
|
+
}
|
|
2672
|
+
/**
|
|
2673
|
+
* Run the full genome extraction pipeline
|
|
2674
|
+
*/
|
|
2675
|
+
async genome(options) {
|
|
2676
|
+
const apiKey = options.apiKey || process.env.ANTHROPIC_API_KEY;
|
|
2677
|
+
if (!apiKey) {
|
|
2678
|
+
throw new Error(
|
|
2679
|
+
"ANTHROPIC_API_KEY is required for genome extraction. Set the environment variable or pass --api-key."
|
|
2680
|
+
);
|
|
2681
|
+
}
|
|
2682
|
+
const outputDir = path6.resolve(options.outputDir);
|
|
2683
|
+
await fs7.ensureDir(outputDir);
|
|
2684
|
+
const model = options.model;
|
|
2685
|
+
this.logger.info("Phase 1/4: Extracting design tokens...");
|
|
2686
|
+
const config = getDefaultConfig();
|
|
2687
|
+
config.browser.headless = options.cdpEndpoint ? false : options.headless ?? true;
|
|
2688
|
+
config.browser.timeout = options.timeout ?? 3e4;
|
|
2689
|
+
config.browser.cdpEndpoint = options.cdpEndpoint;
|
|
2690
|
+
config.output.formats = ["tailwind", "css-variables", "design-tokens"];
|
|
2691
|
+
const orchestrator = new Orchestrator({ config, logger: this.logger });
|
|
2692
|
+
const { result, page, close } = await orchestrator.extractWithPage(options.url, outputDir);
|
|
2693
|
+
try {
|
|
2694
|
+
this.logger.info("Phase 2/4: Vision analysis...");
|
|
2695
|
+
const visionAnalyzer = new VisionAnalyzer({
|
|
2696
|
+
apiKey,
|
|
2697
|
+
model,
|
|
2698
|
+
logger: this.logger
|
|
2699
|
+
});
|
|
2700
|
+
const visionMap = await visionAnalyzer.analyze(page, result, outputDir);
|
|
2701
|
+
this.logger.info(
|
|
2702
|
+
`Vision found ${visionMap.sections.length} sections with ${visionMap.sections.reduce((sum, s) => sum + s.components.length, 0)} components`
|
|
2703
|
+
);
|
|
2704
|
+
this.logger.info("Phase 3/4: Synthesizing components...");
|
|
2705
|
+
const synthesizer = new ComponentSynthesizer({
|
|
2706
|
+
apiKey,
|
|
2707
|
+
model,
|
|
2708
|
+
maxComponents: options.maxComponents ?? 20,
|
|
2709
|
+
logger: this.logger
|
|
2710
|
+
});
|
|
2711
|
+
const components = await synthesizer.synthesize(visionMap, result);
|
|
2712
|
+
if (!options.skipStorybook) {
|
|
2713
|
+
this.logger.info("Generating Storybook stories...");
|
|
2714
|
+
const storyGen = new StoryGenerator({ logger: this.logger });
|
|
2715
|
+
await storyGen.generate(components, outputDir);
|
|
2716
|
+
}
|
|
2717
|
+
this.logger.info("Phase 4/4: Packaging genome...");
|
|
2718
|
+
const serializer = new GenomeSerializer({
|
|
2719
|
+
apiKey,
|
|
2720
|
+
model,
|
|
2721
|
+
logger: this.logger
|
|
2722
|
+
});
|
|
2723
|
+
const genome = await serializer.serialize(
|
|
2724
|
+
result,
|
|
2725
|
+
visionMap,
|
|
2726
|
+
components,
|
|
2727
|
+
outputDir,
|
|
2728
|
+
options.url
|
|
2729
|
+
);
|
|
2730
|
+
if (options.skipScreenshots) {
|
|
2731
|
+
await fs7.remove(path6.join(outputDir, "screenshots"));
|
|
2732
|
+
}
|
|
2733
|
+
return genome;
|
|
2734
|
+
} finally {
|
|
2735
|
+
await close();
|
|
2736
|
+
}
|
|
2737
|
+
}
|
|
2738
|
+
/**
|
|
2739
|
+
* Regenerate a new page from an existing genome
|
|
2740
|
+
*/
|
|
2741
|
+
async regen(options) {
|
|
2742
|
+
const apiKey = options.apiKey || process.env.ANTHROPIC_API_KEY;
|
|
2743
|
+
if (!apiKey) {
|
|
2744
|
+
throw new Error(
|
|
2745
|
+
"ANTHROPIC_API_KEY is required for regeneration. Set the environment variable or pass --api-key."
|
|
2746
|
+
);
|
|
2747
|
+
}
|
|
2748
|
+
const genomeDir = path6.resolve(options.genomeDir);
|
|
2749
|
+
const genomePath = path6.join(genomeDir, "genome.json");
|
|
2750
|
+
if (!await fs7.pathExists(genomePath)) {
|
|
2751
|
+
throw new Error(`genome.json not found in ${genomeDir}`);
|
|
2752
|
+
}
|
|
2753
|
+
const genome = await fs7.readJSON(genomePath);
|
|
2754
|
+
const outputDir = path6.resolve(options.outputDir);
|
|
2755
|
+
const regenerator = new PageRegenerator({
|
|
2756
|
+
apiKey,
|
|
2757
|
+
model: options.model,
|
|
2758
|
+
logger: this.logger
|
|
2759
|
+
});
|
|
2760
|
+
return regenerator.regenerate(
|
|
2761
|
+
genome,
|
|
2762
|
+
genomeDir,
|
|
2763
|
+
options.prompt,
|
|
2764
|
+
outputDir,
|
|
2765
|
+
options.sections
|
|
2766
|
+
);
|
|
2767
|
+
}
|
|
2768
|
+
};
|
|
2769
|
+
|
|
2770
|
+
export {
|
|
2771
|
+
SiteCloner,
|
|
2772
|
+
GenomeEngine
|
|
2773
|
+
};
|