launchframe 0.1.11 → 0.2.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/README.md +126 -181
- package/bin/launchframe.mjs +5 -5
- package/package.json +4 -2
- package/packages/extract/automated-clone-pass.ts +353 -0
- package/packages/extract/cloner-research-emit.ts +270 -0
- package/packages/extract/emit.ts +24 -5
- package/packages/extract/extract.ts +127 -23
- package/packages/extract/host-slug.ts +5 -0
- package/packages/extract/mirror-emit.ts +4 -1
- package/packages/extract/types.ts +5 -0
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Automated recon pass inspired by ai-website-cloner-template Phase 1–2:
|
|
3
|
+
* multi-viewport screenshots, scroll sweep frames, header probe, interaction
|
|
4
|
+
* hints, and optional media downloads — no manual Browser MCP required.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
8
|
+
import { mkdirSync, readFileSync, existsSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { dirname, extname, join } from "node:path";
|
|
10
|
+
|
|
11
|
+
import type { Page } from "playwright";
|
|
12
|
+
|
|
13
|
+
import { hostSlug } from "./host-slug.js";
|
|
14
|
+
|
|
15
|
+
const RESPONSIVE_PRESETS = [
|
|
16
|
+
{ name: "tablet", width: 768, height: 900 },
|
|
17
|
+
{ name: "mobile", width: 390, height: 844 },
|
|
18
|
+
] as const;
|
|
19
|
+
|
|
20
|
+
export interface AutomatedClonePassOptions {
|
|
21
|
+
page: Page;
|
|
22
|
+
host: string;
|
|
23
|
+
outDir: string;
|
|
24
|
+
primaryViewport: { width: number; height: number };
|
|
25
|
+
referenceDir?: string;
|
|
26
|
+
userAgent: string;
|
|
27
|
+
/** When false, skip downloading binaries from media.json (faster / lighter). */
|
|
28
|
+
downloadAssets: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Runs while the Playwright page is still open (after reference + layout).
|
|
33
|
+
* Writes under docs/design-references/<hostSlug>/ and optionally downloaded_assets/.
|
|
34
|
+
*/
|
|
35
|
+
export async function runAutomatedClonePass(opts: AutomatedClonePassOptions): Promise<string[]> {
|
|
36
|
+
const written: string[] = [];
|
|
37
|
+
const slug = hostSlug(opts.host);
|
|
38
|
+
const designRoot = join(opts.outDir, "docs", "design-references", slug);
|
|
39
|
+
mkdirSync(designRoot, { recursive: true });
|
|
40
|
+
|
|
41
|
+
const primary = opts.primaryViewport;
|
|
42
|
+
|
|
43
|
+
await opts.page.setViewportSize({ width: primary.width, height: primary.height });
|
|
44
|
+
await opts.page.waitForTimeout(200);
|
|
45
|
+
|
|
46
|
+
const viewportPrimary = join(designRoot, "viewport-desktop.png");
|
|
47
|
+
await opts.page.screenshot({ path: viewportPrimary, fullPage: false, type: "png" });
|
|
48
|
+
written.push(viewportPrimary);
|
|
49
|
+
|
|
50
|
+
const fullPrimarySrc = join(opts.outDir, "screenshots", `${opts.host}.png`);
|
|
51
|
+
const fullPrimaryDest = join(designRoot, "full-page-desktop.png");
|
|
52
|
+
if (existsSync(fullPrimarySrc)) {
|
|
53
|
+
writeFileSync(fullPrimaryDest, readFileSync(fullPrimarySrc));
|
|
54
|
+
written.push(fullPrimaryDest);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
for (const preset of RESPONSIVE_PRESETS) {
|
|
58
|
+
await opts.page.setViewportSize({ width: preset.width, height: preset.height });
|
|
59
|
+
await opts.page.waitForTimeout(350);
|
|
60
|
+
const fp = join(designRoot, `full-page-${preset.name}.png`);
|
|
61
|
+
await opts.page.screenshot({ path: fp, fullPage: true, type: "png" });
|
|
62
|
+
written.push(fp);
|
|
63
|
+
const vp = join(designRoot, `viewport-${preset.name}.png`);
|
|
64
|
+
await opts.page.screenshot({ path: vp, fullPage: false, type: "png" });
|
|
65
|
+
written.push(vp);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
await opts.page.setViewportSize({ width: primary.width, height: primary.height });
|
|
69
|
+
await opts.page.waitForTimeout(200);
|
|
70
|
+
|
|
71
|
+
const scrollWritten = await scrollSweepScreenshots(opts.page, designRoot);
|
|
72
|
+
written.push(...scrollWritten);
|
|
73
|
+
|
|
74
|
+
await opts.page.evaluate(() => window.scrollTo(0, 0));
|
|
75
|
+
await opts.page.waitForTimeout(150);
|
|
76
|
+
|
|
77
|
+
const probePath = join(designRoot, "header-scroll-probe.json");
|
|
78
|
+
writeFileSync(probePath, JSON.stringify(await headerScrollProbe(opts.page), null, 2) + "\n");
|
|
79
|
+
written.push(probePath);
|
|
80
|
+
|
|
81
|
+
const hintsPath = join(designRoot, "interaction-hints.json");
|
|
82
|
+
writeFileSync(
|
|
83
|
+
hintsPath,
|
|
84
|
+
JSON.stringify(await captureInteractionHints(opts.page), null, 2) + "\n",
|
|
85
|
+
);
|
|
86
|
+
written.push(hintsPath);
|
|
87
|
+
|
|
88
|
+
const readmePath = join(designRoot, "README.md");
|
|
89
|
+
writeFileSync(readmePath, emitDesignReferencesReadme(opts.host, slug));
|
|
90
|
+
written.push(readmePath);
|
|
91
|
+
|
|
92
|
+
if (opts.downloadAssets && opts.referenceDir && existsSync(join(opts.referenceDir, "media.json"))) {
|
|
93
|
+
const assetDir = join(opts.outDir, "downloaded_assets", slug);
|
|
94
|
+
const manifestPath = join(assetDir, "manifest.json");
|
|
95
|
+
const manifest = await downloadMediaFromReference({
|
|
96
|
+
mediaJsonPath: join(opts.referenceDir, "media.json"),
|
|
97
|
+
destDir: join(assetDir, "files"),
|
|
98
|
+
userAgent: opts.userAgent,
|
|
99
|
+
baseUrl: opts.page.url(),
|
|
100
|
+
});
|
|
101
|
+
mkdirSync(dirname(manifestPath), { recursive: true });
|
|
102
|
+
writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + "\n");
|
|
103
|
+
written.push(manifestPath);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return written;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function scrollSweepScreenshots(page: Page, designRoot: string): Promise<string[]> {
|
|
110
|
+
const written: string[] = [];
|
|
111
|
+
await page.evaluate(() => window.scrollTo(0, 0));
|
|
112
|
+
await page.waitForTimeout(150);
|
|
113
|
+
|
|
114
|
+
const scrollHeight = await page.evaluate(() =>
|
|
115
|
+
Math.max(document.documentElement.scrollHeight, document.body.scrollHeight),
|
|
116
|
+
);
|
|
117
|
+
const vh = await page.evaluate(() => window.innerHeight);
|
|
118
|
+
const steps = Math.min(10, Math.max(4, Math.ceil(scrollHeight / Math.max(1, vh))));
|
|
119
|
+
const positions: number[] = [];
|
|
120
|
+
for (let i = 0; i < steps; i++) {
|
|
121
|
+
const y =
|
|
122
|
+
steps <= 1 ? 0 : Math.floor(((scrollHeight - vh) * i) / (steps - 1 || 1));
|
|
123
|
+
positions.push(Math.max(0, y));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
for (let i = 0; i < positions.length; i++) {
|
|
127
|
+
const y = positions[i]!;
|
|
128
|
+
await page.evaluate((yy) => window.scrollTo(0, yy), y);
|
|
129
|
+
await page.waitForTimeout(220);
|
|
130
|
+
const path = join(designRoot, `scroll-${String(i).padStart(2, "0")}-y${y}.png`);
|
|
131
|
+
await page.screenshot({ path, fullPage: false, type: "png" });
|
|
132
|
+
written.push(path);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
await page.evaluate(() => window.scrollTo(0, 0));
|
|
136
|
+
await page.waitForTimeout(120);
|
|
137
|
+
return written;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function headerScrollProbe(page: Page): Promise<unknown> {
|
|
141
|
+
await page.evaluate(() => window.scrollTo(0, 0));
|
|
142
|
+
await page.waitForTimeout(100);
|
|
143
|
+
const top = await probePrimaryChrome(page);
|
|
144
|
+
await page.evaluate(() => window.scrollTo(0, 420));
|
|
145
|
+
await page.waitForTimeout(180);
|
|
146
|
+
const scrolled = await probePrimaryChrome(page);
|
|
147
|
+
await page.evaluate(() => window.scrollTo(0, 0));
|
|
148
|
+
return {
|
|
149
|
+
capturedAt: new Date().toISOString(),
|
|
150
|
+
scrollY0: top,
|
|
151
|
+
scrollY420: scrolled,
|
|
152
|
+
note: "Compare boxShadow/background/position — header transitions suggest scroll-driven chrome.",
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function probePrimaryChrome(page: Page): Promise<unknown> {
|
|
157
|
+
return page.evaluate(() => {
|
|
158
|
+
const pick =
|
|
159
|
+
(document.querySelector("header") as HTMLElement | null) ??
|
|
160
|
+
(document.querySelector('[role="banner"]') as HTMLElement | null) ??
|
|
161
|
+
(document.querySelector("nav") as HTMLElement | null);
|
|
162
|
+
if (!pick || !(pick instanceof HTMLElement)) return null;
|
|
163
|
+
const cs = getComputedStyle(pick);
|
|
164
|
+
const r = pick.getBoundingClientRect();
|
|
165
|
+
return {
|
|
166
|
+
tag: pick.tagName,
|
|
167
|
+
position: cs.position,
|
|
168
|
+
top: Math.round(r.top),
|
|
169
|
+
height: Math.round(r.height),
|
|
170
|
+
boxShadow: cs.boxShadow,
|
|
171
|
+
backgroundColor: cs.backgroundColor,
|
|
172
|
+
backdropFilter: cs.backdropFilter,
|
|
173
|
+
borderBottomWidth: cs.borderBottomWidth,
|
|
174
|
+
borderBottomColor: cs.borderBottomColor,
|
|
175
|
+
};
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function captureInteractionHints(page: Page): Promise<unknown> {
|
|
180
|
+
return page.evaluate(() => {
|
|
181
|
+
const html = document.documentElement;
|
|
182
|
+
const body = document.body;
|
|
183
|
+
const hStyle = getComputedStyle(html);
|
|
184
|
+
const bStyle = getComputedStyle(body);
|
|
185
|
+
const stickySelectors = ["header", "nav", '[role="banner"]'];
|
|
186
|
+
let stickyCount = 0;
|
|
187
|
+
for (const sel of stickySelectors) {
|
|
188
|
+
for (const el of Array.from(document.querySelectorAll(sel))) {
|
|
189
|
+
const p = getComputedStyle(el).position;
|
|
190
|
+
if (p === "sticky" || p === "fixed") stickyCount++;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return {
|
|
194
|
+
capturedAt: new Date().toISOString(),
|
|
195
|
+
likelyLenis:
|
|
196
|
+
!!document.querySelector(
|
|
197
|
+
".lenis, [data-lenis-prevent], [class*='lenis'], .locomotive-scroll",
|
|
198
|
+
),
|
|
199
|
+
htmlScrollSnapType: hStyle.scrollSnapType,
|
|
200
|
+
bodyScrollSnapType: bStyle.scrollSnapType,
|
|
201
|
+
videoCount: document.querySelectorAll("video").length,
|
|
202
|
+
iframeCount: document.querySelectorAll("iframe").length,
|
|
203
|
+
stickyHeaderOrNavCount: stickyCount,
|
|
204
|
+
reducedMotionMedia: window.matchMedia("(prefers-reduced-motion: reduce)").matches,
|
|
205
|
+
};
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function emitDesignReferencesReadme(host: string, slug: string): string {
|
|
210
|
+
return `# Design references — ${host}
|
|
211
|
+
|
|
212
|
+
Automated captures (Landingfram \`automated-clone-pass\`), analogous to **ai-website-cloner-template** \`docs/design-references/\`.
|
|
213
|
+
|
|
214
|
+
| Artifact | Purpose |
|
|
215
|
+
| -------- | ------- |
|
|
216
|
+
| \`full-page-desktop.png\` | Full-page desktop capture (copy of ../screenshots/${host}.png when present). |
|
|
217
|
+
| \`viewport-desktop.png\` | Above-the-fold desktop viewport. |
|
|
218
|
+
| \`full-page-tablet.png\` / \`viewport-tablet.png\` | 768×900 pass. |
|
|
219
|
+
| \`full-page-mobile.png\` / \`viewport-mobile.png\` | 390×844 pass. |
|
|
220
|
+
| \`scroll-*.png\` | Viewport screenshots while scrolling — quick motion / scroll-snap sanity check. |
|
|
221
|
+
| \`header-scroll-probe.json\` | Header/nav computed styles at scroll Y≈0 vs Y≈420. |
|
|
222
|
+
| \`interaction-hints.json\` | Heuristic signals (Lenis-like classes, scroll-snap, sticky chrome counts). |
|
|
223
|
+
|
|
224
|
+
Section crops for builders can be derived from these plus \`../research/${slug}/PAGE_TOPOLOGY.md\`.
|
|
225
|
+
|
|
226
|
+
Compliance: use for structure learning on sites you are allowed to analyze; replace assets when shipping your product.
|
|
227
|
+
`;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
interface DownloadManifest {
|
|
231
|
+
capturedAt: string;
|
|
232
|
+
baseUrl: string;
|
|
233
|
+
downloaded: Array<{ url: string; relativePath: string; bytes: number }>;
|
|
234
|
+
failed: Array<{ url: string; reason: string }>;
|
|
235
|
+
skippedDataUrls: number;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async function downloadMediaFromReference(opts: {
|
|
239
|
+
mediaJsonPath: string;
|
|
240
|
+
destDir: string;
|
|
241
|
+
userAgent: string;
|
|
242
|
+
baseUrl: string;
|
|
243
|
+
}): Promise<DownloadManifest> {
|
|
244
|
+
const manifest: DownloadManifest = {
|
|
245
|
+
capturedAt: new Date().toISOString(),
|
|
246
|
+
baseUrl: opts.baseUrl,
|
|
247
|
+
downloaded: [],
|
|
248
|
+
failed: [],
|
|
249
|
+
skippedDataUrls: 0,
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
const raw = JSON.parse(readFileSync(opts.mediaJsonPath, "utf8")) as {
|
|
253
|
+
media?: Array<
|
|
254
|
+
| { type: string; src?: string | null; poster?: string | null }
|
|
255
|
+
| { type: string; src: string }
|
|
256
|
+
>;
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
const urls = new Set<string>();
|
|
260
|
+
for (const m of raw.media ?? []) {
|
|
261
|
+
if (m.type === "img" && "src" in m && m.src) urls.add(m.src);
|
|
262
|
+
if (m.type === "video") {
|
|
263
|
+
if ("src" in m && m.src) urls.add(m.src);
|
|
264
|
+
if ("poster" in m && m.poster) urls.add(m.poster);
|
|
265
|
+
}
|
|
266
|
+
if (m.type === "source" && "src" in m && m.src) urls.add(m.src);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const MAX_FILES = 72;
|
|
270
|
+
const MAX_BYTES = 12 * 1024 * 1024;
|
|
271
|
+
let n = 0;
|
|
272
|
+
const concurrency = 4;
|
|
273
|
+
const queue = [...urls].filter((u) => {
|
|
274
|
+
if (u.startsWith("data:")) {
|
|
275
|
+
manifest.skippedDataUrls++;
|
|
276
|
+
return false;
|
|
277
|
+
}
|
|
278
|
+
try {
|
|
279
|
+
const parsed = new URL(u);
|
|
280
|
+
return parsed.protocol === "https:" || parsed.protocol === "http:";
|
|
281
|
+
} catch {
|
|
282
|
+
return false;
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
mkdirSync(opts.destDir, { recursive: true });
|
|
287
|
+
|
|
288
|
+
async function worker(batch: string[]) {
|
|
289
|
+
for (const url of batch) {
|
|
290
|
+
if (n >= MAX_FILES) break;
|
|
291
|
+
const ok = await downloadOne(url, opts.destDir, opts.userAgent, MAX_BYTES);
|
|
292
|
+
if (ok.ok) {
|
|
293
|
+
manifest.downloaded.push({ url, relativePath: ok.relativePath, bytes: ok.bytes });
|
|
294
|
+
n++;
|
|
295
|
+
} else {
|
|
296
|
+
manifest.failed.push({ url, reason: ok.reason });
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
for (let i = 0; i < queue.length; i += concurrency) {
|
|
302
|
+
await worker(queue.slice(i, i + concurrency));
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return manifest;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async function downloadOne(
|
|
309
|
+
url: string,
|
|
310
|
+
destDir: string,
|
|
311
|
+
userAgent: string,
|
|
312
|
+
maxBytes: number,
|
|
313
|
+
): Promise<
|
|
314
|
+
{ ok: true; relativePath: string; bytes: number } | { ok: false; reason: string }
|
|
315
|
+
> {
|
|
316
|
+
try {
|
|
317
|
+
const res = await fetch(url, {
|
|
318
|
+
headers: { "User-Agent": userAgent, Accept: "*/*" },
|
|
319
|
+
redirect: "follow",
|
|
320
|
+
});
|
|
321
|
+
if (!res.ok) return { ok: false, reason: `HTTP ${res.status}` };
|
|
322
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
323
|
+
if (buf.length > maxBytes) return { ok: false, reason: "too large" };
|
|
324
|
+
const hash = createHash("sha256").update(url).digest("hex").slice(0, 12);
|
|
325
|
+
const ext = guessExt(url, res.headers.get("content-type"));
|
|
326
|
+
const fileName = `${hash}${ext}`;
|
|
327
|
+
const path = join(destDir, fileName);
|
|
328
|
+
writeFileSync(path, buf);
|
|
329
|
+
const rel = `files/${fileName}`;
|
|
330
|
+
return { ok: true, relativePath: rel.replace(/\\/g, "/"), bytes: buf.length };
|
|
331
|
+
} catch (e) {
|
|
332
|
+
return { ok: false, reason: (e as Error).message };
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function guessExt(url: string, contentType: string | null): string {
|
|
337
|
+
try {
|
|
338
|
+
const fromPath = extname(new URL(url).pathname.split("?")[0] ?? "");
|
|
339
|
+
if (/^\.\w{2,5}$/.test(fromPath)) return fromPath.slice(0, 10);
|
|
340
|
+
} catch {
|
|
341
|
+
/* ignore */
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (!contentType) return ".bin";
|
|
345
|
+
if (contentType.includes("jpeg")) return ".jpg";
|
|
346
|
+
if (contentType.includes("png")) return ".png";
|
|
347
|
+
if (contentType.includes("webp")) return ".webp";
|
|
348
|
+
if (contentType.includes("gif")) return ".gif";
|
|
349
|
+
if (contentType.includes("svg")) return ".svg";
|
|
350
|
+
if (contentType.includes("mp4")) return ".mp4";
|
|
351
|
+
if (contentType.includes("webm")) return ".webm";
|
|
352
|
+
return ".bin";
|
|
353
|
+
}
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Emits research artifacts under each extract run, aligned in layout and intent
|
|
3
|
+
* with the AI Website Cloner template (topology, behaviors bible, per-section
|
|
4
|
+
* specs). Values are derived from Landingfram's automated crawl where available;
|
|
5
|
+
* interactive sweeps still require manual Browser MCP work for full fidelity.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { dirname, join } from "node:path";
|
|
10
|
+
|
|
11
|
+
import type { ExtractionRun, SectionLayout, SiteLayout } from "./types.js";
|
|
12
|
+
import { hostSlug } from "./host-slug.js";
|
|
13
|
+
|
|
14
|
+
export function emitClonerResearch(
|
|
15
|
+
run: ExtractionRun,
|
|
16
|
+
layoutsByHost: Map<string, SiteLayout>,
|
|
17
|
+
): string[] {
|
|
18
|
+
const written: string[] = [];
|
|
19
|
+
const root = join(run.outputDir, "docs", "research");
|
|
20
|
+
mkdirSync(root, { recursive: true });
|
|
21
|
+
|
|
22
|
+
written.push(
|
|
23
|
+
writeFile(
|
|
24
|
+
join(root, "README.md"),
|
|
25
|
+
emitResearchReadme(run, layoutsByHost.size > 0),
|
|
26
|
+
),
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
for (const cap of run.captures) {
|
|
30
|
+
if (cap.status !== "ok") continue;
|
|
31
|
+
const layout = layoutsByHost.get(cap.host);
|
|
32
|
+
const hostDir = join(root, hostSlug(cap.host));
|
|
33
|
+
mkdirSync(join(hostDir, "components"), { recursive: true });
|
|
34
|
+
|
|
35
|
+
written.push(
|
|
36
|
+
writeFile(join(hostDir, "PAGE_TOPOLOGY.md"), emitTopology(cap.url, cap.host, layout)),
|
|
37
|
+
);
|
|
38
|
+
written.push(
|
|
39
|
+
writeFile(
|
|
40
|
+
join(hostDir, "BEHAVIORS.md"),
|
|
41
|
+
emitBehaviors(cap.url, cap.host, run, layout),
|
|
42
|
+
),
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
if (layout) {
|
|
46
|
+
for (const section of layout.sections) {
|
|
47
|
+
const name = `${section.id}-${section.role}`.replace(/[^a-z0-9-]+/gi, "-");
|
|
48
|
+
written.push(
|
|
49
|
+
writeFile(
|
|
50
|
+
join(hostDir, "components", `${name}.spec.md`),
|
|
51
|
+
emitSectionSpec(cap.host, layout, section),
|
|
52
|
+
),
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return written;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function writeFile(path: string, body: string): string {
|
|
62
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
63
|
+
writeFileSync(path, body, "utf8");
|
|
64
|
+
return path;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function automatedJsonAppend(outputDir: string, host: string): string {
|
|
68
|
+
const slug = hostSlug(host);
|
|
69
|
+
const base = join(outputDir, "docs", "design-references", slug);
|
|
70
|
+
const probe = join(base, "header-scroll-probe.json");
|
|
71
|
+
const hints = join(base, "interaction-hints.json");
|
|
72
|
+
const chunks: string[] = [];
|
|
73
|
+
if (existsSync(probe)) {
|
|
74
|
+
chunks.push("\n### Embedded: header-scroll-probe.json\n\n```json\n");
|
|
75
|
+
chunks.push(readFileSync(probe, "utf8").trimEnd());
|
|
76
|
+
chunks.push("\n```\n");
|
|
77
|
+
}
|
|
78
|
+
if (existsSync(hints)) {
|
|
79
|
+
chunks.push("\n### Embedded: interaction-hints.json\n\n```json\n");
|
|
80
|
+
chunks.push(readFileSync(hints, "utf8").trimEnd());
|
|
81
|
+
chunks.push("\n```\n");
|
|
82
|
+
}
|
|
83
|
+
const manifest = join(outputDir, "downloaded_assets", slug, "manifest.json");
|
|
84
|
+
if (existsSync(manifest)) {
|
|
85
|
+
chunks.push(
|
|
86
|
+
`\n### Asset downloads\n\nLocal manifest: \`downloaded_assets/${slug}/manifest.json\`\n`,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
return chunks.join("");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function emitResearchReadme(run: ExtractionRun, hasLayouts: boolean): string {
|
|
93
|
+
const idea =
|
|
94
|
+
run.saasIdea?.trim() ||
|
|
95
|
+
"_No SaaS idea passed. Re-run with:_ `npx launchframe <url> \"your idea\"` _or_ `--idea \"...\"`.";
|
|
96
|
+
|
|
97
|
+
const layoutNote = hasLayouts
|
|
98
|
+
? ""
|
|
99
|
+
: "\n**Note:** No `SiteLayout` was available for any host (DOM crawl may have failed). Topology files are stubbed; rely on `reference/<host>/` + tokens.\n";
|
|
100
|
+
|
|
101
|
+
return `# Research artifacts
|
|
102
|
+
|
|
103
|
+
This folder mirrors the **artifact roles** of [ai-website-cloner-template](https://github.com/JCodesMore/ai-website-cloner-template) (\`PAGE_TOPOLOGY.md\`, \`BEHAVIORS.md\`, \`components/*.spec.md\`) so coding agents can follow the same **recon → specs → build** cadence.
|
|
104
|
+
|
|
105
|
+
**Run:** \`${run.runId}\`
|
|
106
|
+
**Output:** \`${run.outputDir.replace(/\\/g, "/")}\`
|
|
107
|
+
|
|
108
|
+
## Operator SaaS idea
|
|
109
|
+
|
|
110
|
+
${idea}
|
|
111
|
+
|
|
112
|
+
## Per-host paths
|
|
113
|
+
|
|
114
|
+
For each captured host: \`<host>/PAGE_TOPOLOGY.md\`, \`<host>/BEHAVIORS.md\`, \`<host>/components/*.spec.md\`
|
|
115
|
+
|
|
116
|
+
Screenshots for visual QA live in \`../design-references/<host-slug>/\` (multi-viewport + scroll sweep). Downloaded binaries (when enabled): \`../downloaded_assets/<host-slug>/manifest.json\`.
|
|
117
|
+
${layoutNote}`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function emitTopology(url: string, host: string, layout: SiteLayout | undefined): string {
|
|
121
|
+
const lines: string[] = [
|
|
122
|
+
`# Page topology`,
|
|
123
|
+
``,
|
|
124
|
+
`- **URL:** ${url}`,
|
|
125
|
+
`- **Host:** ${host}`,
|
|
126
|
+
`- **Generated by:** Landingfram automated crawl (primary viewport + optional responsive passes under \`docs/design-references/\`)`,
|
|
127
|
+
``,
|
|
128
|
+
`## Section order (top → bottom)`,
|
|
129
|
+
``,
|
|
130
|
+
];
|
|
131
|
+
|
|
132
|
+
if (!layout?.sections.length) {
|
|
133
|
+
lines.push(
|
|
134
|
+
`_No section model — see \`reference/${host}/structure-outline.txt\` and \`dom-structure.json\`._`,
|
|
135
|
+
);
|
|
136
|
+
return lines.join("\n");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
for (const s of layout.sections) {
|
|
140
|
+
const [x, y, w, h] = s.bbox;
|
|
141
|
+
lines.push(
|
|
142
|
+
`### ${s.id} — ${s.role}`,
|
|
143
|
+
`- **Composition:** ${s.composition}`,
|
|
144
|
+
`- **Density:** ${s.density}`,
|
|
145
|
+
`- **BBox (normalized):** x=${x.toFixed(3)}, y=${y.toFixed(3)}, w=${w.toFixed(3)}, h=${h.toFixed(3)}`,
|
|
146
|
+
`- **Slots:** ${s.slots.map((sl) => `${sl.kind}×${sl.count}`).join(", ") || "_none_"}`,
|
|
147
|
+
`- **Notes:** ${s.notes.join("; ") || "—"}`,
|
|
148
|
+
``,
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
lines.push(
|
|
153
|
+
`## Fixed / sticky overlays`,
|
|
154
|
+
``,
|
|
155
|
+
`_Not inferred automatically. Inspect \`reference/${host}/page.html\` or re-scan with Browser MCP per [clone-website skill](https://github.com/JCodesMore/ai-website-cloner-template/blob/master/.claude/skills/clone-website/SKILL.md)._`,
|
|
156
|
+
``,
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
return lines.join("\n");
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function emitBehaviors(
|
|
163
|
+
url: string,
|
|
164
|
+
host: string,
|
|
165
|
+
run: ExtractionRun,
|
|
166
|
+
layout: SiteLayout | undefined,
|
|
167
|
+
): string {
|
|
168
|
+
const vpLine = layout?.viewport
|
|
169
|
+
? `${layout.viewport.width}×${layout.viewport.height}px`
|
|
170
|
+
: "See crawl viewport in run.json / raw/*.layout.json";
|
|
171
|
+
|
|
172
|
+
return `# Behaviors bible
|
|
173
|
+
|
|
174
|
+
- **URL:** ${url}
|
|
175
|
+
- **Capture viewport:** ${vpLine}
|
|
176
|
+
|
|
177
|
+
## What Landingfram captured automatically
|
|
178
|
+
|
|
179
|
+
- Single desktop pass in headless Chromium with **reduced motion** forced for deterministic screenshots.
|
|
180
|
+
- **Automated multi-viewport + scroll sweep** PNGs under \`docs/design-references/${hostSlug(host)}/\` (desktop/tablet/mobile + scroll frames).
|
|
181
|
+
- **Header scroll probe** (\`header-scroll-probe.json\`) and **interaction hints** (\`interaction-hints.json\`) — check for scroll-driven chrome, Lenis-like wrappers, scroll-snap.
|
|
182
|
+
- Computed-style tokens and a typed **section scaffold** when DOM crawl succeeds (\`raw/${host}.layout.json\`, \`mirror/${host}/page.tsx\`).
|
|
183
|
+
${automatedJsonAppend(run.outputDir, host)}
|
|
184
|
+
|
|
185
|
+
## What still needs a manual interaction sweep
|
|
186
|
+
|
|
187
|
+
Use Browser MCP (or the upstream **clone-website** workflow) to fill gaps — match their checklist:
|
|
188
|
+
|
|
189
|
+
1. **Scroll sweep** — header shrink, scroll-driven tabs, scroll-snap, sticky sidebars, Lenis / Locomotive markers.
|
|
190
|
+
2. **Click sweep** — modals, dropdowns, tab panels; extract **every** tab state.
|
|
191
|
+
3. **Hover sweep** — transitions (duration + easing), not just before/after colors.
|
|
192
|
+
4. **Responsive sweep** — 1440 / 768 / 390 viewports with breakpoint notes.
|
|
193
|
+
|
|
194
|
+
Document findings below as you discover them.
|
|
195
|
+
|
|
196
|
+
## Recorded behaviors (manual)
|
|
197
|
+
|
|
198
|
+
| Area | Interaction model | Trigger | State A | State B | Transition |
|
|
199
|
+
|------|---------------------|---------|---------|---------|------------|
|
|
200
|
+
| _(empty)_ | | | | | |
|
|
201
|
+
|
|
202
|
+
---
|
|
203
|
+
|
|
204
|
+
## Operator SaaS idea
|
|
205
|
+
|
|
206
|
+
${run.saasIdea?.trim() || "_Pass `--idea` or a second positional string when invoking landingfram._"}
|
|
207
|
+
|
|
208
|
+
When rebuilding UI, preserve **interaction models** from this table (once filled); rewrite **copy** to match the SaaS idea without impersonating the reference brand.
|
|
209
|
+
`;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function emitSectionSpec(host: string, layout: SiteLayout, s: SectionLayout): string {
|
|
213
|
+
const mirrorHint = `mirror/${host}/page.tsx — section marker starts with \`${s.id}:\` inside data-mirror-section`;
|
|
214
|
+
const slotsMd = s.slots.map((sl) => `- **${sl.kind}** × ${sl.count}`).join("\n");
|
|
215
|
+
|
|
216
|
+
return `# Section specification — ${s.id}
|
|
217
|
+
|
|
218
|
+
## Overview
|
|
219
|
+
|
|
220
|
+
- **Source URL:** ${layout.url}
|
|
221
|
+
- **Mirror:** \`${mirrorHint}\`
|
|
222
|
+
- **Interaction model:** _unknown — infer via scroll-first sweep per clone-website skill; default treated as **static** for this automated extract._
|
|
223
|
+
|
|
224
|
+
## Section summary
|
|
225
|
+
|
|
226
|
+
- **Role:** ${s.role}
|
|
227
|
+
- **Composition:** ${s.composition}
|
|
228
|
+
- **Density:** ${s.density}
|
|
229
|
+
|
|
230
|
+
## Layout hints (from crawl)
|
|
231
|
+
|
|
232
|
+
### Container style hints
|
|
233
|
+
|
|
234
|
+
- **Background:** ${s.styles.backgroundHex ?? "—"}
|
|
235
|
+
- **Foreground:** ${s.styles.foregroundHex ?? "—"}
|
|
236
|
+
- **Padding block:** top ${s.styles.paddingTopPx ?? "—"}px / bottom ${s.styles.paddingBottomPx ?? "—"}px
|
|
237
|
+
|
|
238
|
+
### Site tokens (page-level)
|
|
239
|
+
|
|
240
|
+
- **Body font:** ${layout.tokens.bodyFontFamily}
|
|
241
|
+
- **Heading font:** ${layout.tokens.headingFontFamily}
|
|
242
|
+
- **Background / foreground:** ${layout.tokens.backgroundHex} / ${layout.tokens.foregroundHex}
|
|
243
|
+
- **Primary:** ${layout.tokens.primaryHex}
|
|
244
|
+
- **Radius:** ${layout.tokens.radiusPx}px
|
|
245
|
+
|
|
246
|
+
## Slots (mirror placeholders)
|
|
247
|
+
|
|
248
|
+
${slotsMd || "_None_"}
|
|
249
|
+
|
|
250
|
+
Fill each \`<TextSlot>\` / \`<MediaSlot>\` using **verbatim** strings from \`reference/${host}/visible-text.txt\` only when you own rights or are authorized; otherwise substitute original copy aligned with the operator SaaS idea.
|
|
251
|
+
|
|
252
|
+
## States & behaviors
|
|
253
|
+
|
|
254
|
+
_N/A for automated single-state crawl — add rows after MCP extraction._
|
|
255
|
+
|
|
256
|
+
## Assets
|
|
257
|
+
|
|
258
|
+
- Crawl index: \`reference/${host}/media.json\` (respect licensing).
|
|
259
|
+
- When downloads ran (no \`--no-download\`): \`downloaded_assets/${hostSlug(host)}/manifest.json\` maps remote URLs to \`files/*\`.
|
|
260
|
+
|
|
261
|
+
## Responsive behavior
|
|
262
|
+
|
|
263
|
+
Automated PNGs live under \`docs/design-references/${hostSlug(host)}/\` (tablet 768×900, mobile 390×844, scroll sweep). Refine breakpoints using those artifacts alongside the mirror scaffold.
|
|
264
|
+
|
|
265
|
+
## Text content
|
|
266
|
+
|
|
267
|
+
See \`reference/${host}/visible-text.json\` / \`.txt\`. Map strings into slots by section order and landmark roles.
|
|
268
|
+
|
|
269
|
+
`;
|
|
270
|
+
}
|
package/packages/extract/emit.ts
CHANGED
|
@@ -44,7 +44,7 @@ function write(dir: string, file: string, contents: string): string {
|
|
|
44
44
|
|
|
45
45
|
function emitTailwindConfig(system: DesignSystem): string {
|
|
46
46
|
return `/**
|
|
47
|
-
* Tailwind theme extracted by
|
|
47
|
+
* Tailwind theme extracted by landingfram.
|
|
48
48
|
* Run id: ${system.runId}
|
|
49
49
|
*
|
|
50
50
|
* Sources (inspirational only, no source code or assets reused):
|
|
@@ -126,7 +126,7 @@ function heightFor(step: string, system: DesignSystem): string {
|
|
|
126
126
|
|
|
127
127
|
function emitGlobalsCss(system: DesignSystem): string {
|
|
128
128
|
return `/**
|
|
129
|
-
* Drop-in CSS variables produced by
|
|
129
|
+
* Drop-in CSS variables produced by landingfram.
|
|
130
130
|
* Compatible with shadcn/ui's --background / --foreground / etc. tokens.
|
|
131
131
|
*/
|
|
132
132
|
|
|
@@ -424,6 +424,19 @@ function emitForAi(system: DesignSystem, run: ExtractionRun): string {
|
|
|
424
424
|
const ok = run.captures.filter((c) => c.status === "ok");
|
|
425
425
|
const hasReference = ok.some((c) => c.referenceDir);
|
|
426
426
|
const hasMirror = ok.some((c) => c.mirrorDir);
|
|
427
|
+
const ideaSection = run.saasIdea?.trim()
|
|
428
|
+
? `
|
|
429
|
+
|
|
430
|
+
## Operator SaaS idea (from CLI)
|
|
431
|
+
|
|
432
|
+
${run.saasIdea.trim()}
|
|
433
|
+
|
|
434
|
+
Use this narrative when replacing mirror placeholders and reference copy: headings, subheads, and CTAs should describe **your** product. Do not present another brand's trademarks or product name as yours.
|
|
435
|
+
`
|
|
436
|
+
: "";
|
|
437
|
+
const intentPaste =
|
|
438
|
+
run.saasIdea?.trim() ??
|
|
439
|
+
"[describe goal — faithful mirror of URL vs new product in same layout; tone and CTA]";
|
|
427
440
|
const perHost = ok
|
|
428
441
|
.filter((c) => c.referenceDir || c.mirrorDir)
|
|
429
442
|
.map((c) => {
|
|
@@ -470,6 +483,10 @@ Attach this entire \`output/${system.runId}/\` folder (or copy it into the app r
|
|
|
470
483
|
**When rebuilding from a crawled URL, also attach (per host):**
|
|
471
484
|
\`reference/<host>/\` **and** \`mirror/<host>/\` — the model must see both **verbatim DOM/copy** and the **typed React scaffold**.
|
|
472
485
|
|
|
486
|
+
**For clone-style parallel builders:** attach \`docs/research/\` (per-host topology, behaviors checklist, component specs).
|
|
487
|
+
|
|
488
|
+
**Automated recon (design references):** \`docs/design-references/<host-slug>/\` — desktop/tablet/mobile captures, scroll sweep frames, header scroll probe, interaction hints — plus optional \`downloaded_assets/<host-slug>/manifest.json\`.
|
|
489
|
+
|
|
473
490
|
In **Cursor**, \`@\` those paths explicitly.
|
|
474
491
|
|
|
475
492
|
## Authority order
|
|
@@ -477,7 +494,9 @@ In **Cursor**, \`@\` those paths explicitly.
|
|
|
477
494
|
1. **Structural fidelity:** \`reference/<host>/dom-structure.json\` + \`page.html\` + \`visible-text.*\` + \`mirror/<host>/page.tsx\` — exact DOM tree shape, then copy and typed scaffold.
|
|
478
495
|
2. **Design tokens:** **REPORT.md** and **tokens.json** — typography scale, spacing, radii, colors, container width, notes.
|
|
479
496
|
3. **Integration:** **tailwind.config.ts** + **globals.css** — merge into a Next.js + Tailwind + shadcn-style app.
|
|
480
|
-
|
|
497
|
+
4. **Builder specs:** **docs/research/** — per-host \`PAGE_TOPOLOGY.md\`, \`BEHAVIORS.md\`, \`components/*.spec.md\`.
|
|
498
|
+
5. **Visual parity:** **docs/design-references/** — screenshots + automated probes for scroll/chrome behavior (supplement with manual MCP for tab/modal states).
|
|
499
|
+
${ideaSection}${structureSection}
|
|
481
500
|
## Instruction block (paste into chat)
|
|
482
501
|
|
|
483
502
|
\`\`\`text
|
|
@@ -488,13 +507,13 @@ You must use the attached \`output/${system.runId}/\` folder.
|
|
|
488
507
|
- If reference/ and mirror/ exist for my source URL: treat them as mandatory context. Preserve **exact DOM nesting and sibling order** from \`dom-structure.json\` (and cross-check \`page.html\`). Align components to \`data-mirror-section\` and the mirror scaffold. Wire copy from visible-text.* and media from media.json unless I say to rewrite for a different product.
|
|
489
508
|
- If I am building a NEW product unrelated to the crawl: keep layout inspiration from mirror/reference but REPLACE product names, claims, and sensitive copy with my copy. Never impersonate another brand.
|
|
490
509
|
|
|
491
|
-
My product / intent:
|
|
510
|
+
My product / intent: ${intentPaste}
|
|
492
511
|
\`\`\`
|
|
493
512
|
|
|
494
513
|
## After the agent runs
|
|
495
514
|
|
|
496
515
|
- Compare against **theme-preview.tsx** to verify token usage.
|
|
497
|
-
- Iterate with REPORT.md + reference/ + mirror/ in context.
|
|
516
|
+
- Iterate with REPORT.md + reference/ + mirror/ + **docs/research/** + **docs/design-references/** in context.
|
|
498
517
|
`;
|
|
499
518
|
}
|
|
500
519
|
|