launchframe 0.1.10 → 0.1.13
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 +118 -181
- package/package.json +1 -1
- package/packages/extract/automated-clone-pass.ts +353 -0
- package/packages/extract/cloner-research-emit.ts +270 -0
- package/packages/extract/emit.ts +22 -3
- package/packages/extract/extract.ts +126 -22
- package/packages/extract/host-slug.ts +5 -0
- package/packages/extract/mirror-emit.ts +4 -1
- package/packages/extract/types.ts +5 -0
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `extract` — the headline command.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* npx launchframe@latest https://site-a.com "Your SaaS idea"
|
|
5
|
+
* npm run extract -- https://site-a.com "idea" https://site-b.com
|
|
5
6
|
*
|
|
6
7
|
* For each URL: open in Chromium, screenshot, harvest computed design
|
|
7
8
|
* tokens via `browser-extract.ts`, and crawl the rendered DOM into a
|
|
@@ -22,6 +23,9 @@
|
|
|
22
23
|
* computed style tokens, content kinds) and writes a verbatim
|
|
23
24
|
* `reference/<host>/` bundle (HTML, DOM tree JSON, outlines, visible text,
|
|
24
25
|
* media index) for AI structure cloning.
|
|
26
|
+
* - Automated **design-references** pass: responsive + scroll sweep PNGs,
|
|
27
|
+
* header probe, interaction hints, optional media downloads (like the
|
|
28
|
+
* ai-website-cloner-template recon outputs).
|
|
25
29
|
*/
|
|
26
30
|
|
|
27
31
|
import { mkdirSync, writeFileSync } from "node:fs";
|
|
@@ -31,8 +35,11 @@ import { fileURLToPath, pathToFileURL } from "node:url";
|
|
|
31
35
|
import { chromium, type Browser } from "playwright";
|
|
32
36
|
|
|
33
37
|
import { harvestTokens } from "./browser-extract.js";
|
|
38
|
+
import { runAutomatedClonePass } from "./automated-clone-pass.js";
|
|
34
39
|
import { crawlLayout } from "./dom-crawler.js";
|
|
40
|
+
import { emitClonerResearch } from "./cloner-research-emit.js";
|
|
35
41
|
import { emitAll } from "./emit.js";
|
|
42
|
+
import { emitMirror } from "./mirror-emit.js";
|
|
36
43
|
import { emitPageReference } from "./reference-dump.js";
|
|
37
44
|
import { synthesize } from "./synthesize.js";
|
|
38
45
|
import type { ExtractionRun, RawTokens, SiteCapture, SiteLayout } from "./types.js";
|
|
@@ -52,38 +59,98 @@ interface CliArgs {
|
|
|
52
59
|
respectRobots: boolean;
|
|
53
60
|
rateLimitPerMinute: number;
|
|
54
61
|
runName?: string;
|
|
62
|
+
/** SaaS product narrative from `--idea`, a positional string, or LAUNCHFRAME_SAAS_IDEA. */
|
|
63
|
+
saasIdea?: string;
|
|
64
|
+
/** When true, skip HTTP downloads from reference/media.json */
|
|
65
|
+
skipAssetDownload: boolean;
|
|
55
66
|
}
|
|
56
67
|
|
|
57
68
|
function parseArgs(argv: string[]): CliArgs {
|
|
69
|
+
const urls: string[] = [];
|
|
70
|
+
const positional: string[] = [];
|
|
58
71
|
const args: CliArgs = {
|
|
59
|
-
urls
|
|
72
|
+
urls,
|
|
60
73
|
outDir: "",
|
|
61
74
|
viewport: { width: 1440, height: 900 },
|
|
62
75
|
respectRobots: true,
|
|
63
76
|
rateLimitPerMinute: 15,
|
|
77
|
+
skipAssetDownload: false,
|
|
64
78
|
};
|
|
79
|
+
|
|
65
80
|
for (let i = 0; i < argv.length; i++) {
|
|
66
81
|
const a = argv[i]!;
|
|
67
|
-
if (a === "--
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
82
|
+
if (a === "--idea") {
|
|
83
|
+
const v = argv[++i];
|
|
84
|
+
if (!v) {
|
|
85
|
+
console.error("--idea requires a value");
|
|
86
|
+
process.exit(2);
|
|
87
|
+
}
|
|
88
|
+
args.saasIdea = v;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (a === "--no-download") {
|
|
92
|
+
args.skipAssetDownload = true;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (a === "--out") {
|
|
96
|
+
args.outDir = argv[++i]!;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (a === "--name") {
|
|
100
|
+
args.runName = argv[++i];
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (a === "--no-robots") {
|
|
104
|
+
args.respectRobots = false;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (a === "--rate") {
|
|
108
|
+
args.rateLimitPerMinute = parseInt(argv[++i]!, 10);
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (a === "--width") {
|
|
112
|
+
args.viewport.width = parseInt(argv[++i]!, 10);
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (a === "--height") {
|
|
116
|
+
args.viewport.height = parseInt(argv[++i]!, 10);
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (a === "--help" || a === "-h") {
|
|
74
120
|
printHelp();
|
|
75
121
|
process.exit(0);
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
|
|
122
|
+
}
|
|
123
|
+
if (a.startsWith("http://") || a.startsWith("https://")) {
|
|
124
|
+
urls.push(a);
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
if (a.startsWith("--")) {
|
|
79
128
|
console.error(`Unknown flag: ${a}`);
|
|
80
129
|
process.exit(2);
|
|
81
|
-
}
|
|
82
|
-
|
|
130
|
+
}
|
|
131
|
+
positional.push(a);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (positional.length > 1) {
|
|
135
|
+
console.error(
|
|
136
|
+
'Too many positional arguments. Quote your SaaS idea as one argument or use --idea "..."',
|
|
137
|
+
);
|
|
138
|
+
process.exit(2);
|
|
139
|
+
}
|
|
140
|
+
if (positional.length === 1) {
|
|
141
|
+
if (args.saasIdea) {
|
|
142
|
+
console.error("Use either --idea or one positional SaaS idea string, not both.");
|
|
83
143
|
process.exit(2);
|
|
84
144
|
}
|
|
145
|
+
args.saasIdea = positional[0];
|
|
85
146
|
}
|
|
86
|
-
|
|
147
|
+
|
|
148
|
+
const envIdea = process.env.LAUNCHFRAME_SAAS_IDEA?.trim();
|
|
149
|
+
if (envIdea && !args.saasIdea) {
|
|
150
|
+
args.saasIdea = envIdea;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (urls.length === 0) {
|
|
87
154
|
printHelp();
|
|
88
155
|
process.exit(2);
|
|
89
156
|
}
|
|
@@ -94,8 +161,9 @@ function printHelp(): void {
|
|
|
94
161
|
console.log(
|
|
95
162
|
[
|
|
96
163
|
"Usage:",
|
|
97
|
-
" npx launchframe <url> [<url> ...] [options]
|
|
98
|
-
"
|
|
164
|
+
" npx launchframe@latest <url> [\"SaaS idea\"] [<url> ...] [options]",
|
|
165
|
+
" npx launchframe@latest <url> --idea \"SaaS idea\" [options]",
|
|
166
|
+
" npm run extract -- <url> [\"idea\"] [...] [options] (from this repo)",
|
|
99
167
|
"",
|
|
100
168
|
"Writes to ./output/<runId>/ in your current working directory unless",
|
|
101
169
|
"you pass --out.",
|
|
@@ -113,8 +181,14 @@ function printHelp(): void {
|
|
|
113
181
|
"After every URL, a drop-in shadcn-compatible design system is",
|
|
114
182
|
"synthesized from the aggregated tokens and written to output/<runId>/.",
|
|
115
183
|
"",
|
|
184
|
+
" Your SaaS idea is stored in run.json, FOR_AI.md, and docs/research/ for agents.",
|
|
185
|
+
"",
|
|
116
186
|
"Options:",
|
|
117
|
-
" --
|
|
187
|
+
" --idea <text> SaaS product narrative (alternative to one positional string)",
|
|
188
|
+
" --no-download Skip downloading binaries listed in reference/*/media.json",
|
|
189
|
+
" --out <dir> Run directory (default: ./output/<runId>). Use e.g.",
|
|
190
|
+
" ./my-next-app/extraction-acme to mirror template-style",
|
|
191
|
+
" docs/ placement inside a consumer repo.",
|
|
118
192
|
" --name <slug> Human-friendly slug used in the runId",
|
|
119
193
|
" --no-robots Skip robots.txt check (not recommended)",
|
|
120
194
|
" --rate <per-min> Per-domain rate limit, default 15",
|
|
@@ -197,6 +271,7 @@ async function captureOne(
|
|
|
197
271
|
url: string,
|
|
198
272
|
viewport: { width: number; height: number },
|
|
199
273
|
outDir: string,
|
|
274
|
+
opts: { skipAssetDownload: boolean },
|
|
200
275
|
): Promise<{ raw: RawTokens; layout: SiteLayout | null; capture: SiteCapture } | null> {
|
|
201
276
|
const host = new URL(url).host;
|
|
202
277
|
const stamp = `${host}.png`;
|
|
@@ -214,7 +289,7 @@ async function captureOne(
|
|
|
214
289
|
});
|
|
215
290
|
const page = await ctx.newPage();
|
|
216
291
|
try {
|
|
217
|
-
const response = await page.goto(url, { waitUntil: "
|
|
292
|
+
const response = await page.goto(url, { waitUntil: "load", timeout: 60_000 });
|
|
218
293
|
if (!response || response.status() >= 400) {
|
|
219
294
|
throw new Error(`HTTP ${response?.status() ?? "unknown"}`);
|
|
220
295
|
}
|
|
@@ -255,6 +330,23 @@ async function captureOne(
|
|
|
255
330
|
console.warn(` ! layout crawl failed for ${url}: ${(err as Error).message}`);
|
|
256
331
|
}
|
|
257
332
|
|
|
333
|
+
try {
|
|
334
|
+
const n = (
|
|
335
|
+
await runAutomatedClonePass({
|
|
336
|
+
page,
|
|
337
|
+
host,
|
|
338
|
+
outDir,
|
|
339
|
+
primaryViewport: viewport,
|
|
340
|
+
referenceDir: referenceWritten.length > 0 ? referenceDir : undefined,
|
|
341
|
+
userAgent: USER_AGENT,
|
|
342
|
+
downloadAssets: !opts.skipAssetDownload,
|
|
343
|
+
})
|
|
344
|
+
).length;
|
|
345
|
+
if (n > 0) console.log(` · automated recon (${n} files under docs/design-references & downloads)`);
|
|
346
|
+
} catch (err) {
|
|
347
|
+
console.warn(` ! automated recon failed for ${url}: ${(err as Error).message}`);
|
|
348
|
+
}
|
|
349
|
+
|
|
258
350
|
const capture: SiteCapture = {
|
|
259
351
|
url,
|
|
260
352
|
host,
|
|
@@ -276,7 +368,6 @@ async function captureOne(
|
|
|
276
368
|
host,
|
|
277
369
|
capturedAt: new Date().toISOString(),
|
|
278
370
|
screenshotPath: "",
|
|
279
|
-
rawTokensPath: "",
|
|
280
371
|
status: "failed",
|
|
281
372
|
reason: (err as Error).message,
|
|
282
373
|
},
|
|
@@ -309,6 +400,9 @@ async function main(): Promise<void> {
|
|
|
309
400
|
console.log(`[extract] runId=${runId}`);
|
|
310
401
|
console.log(`[extract] urls=${args.urls.length} viewport=${args.viewport.width}x${args.viewport.height}`);
|
|
311
402
|
console.log(`[extract] output=${outDir}`);
|
|
403
|
+
if (args.saasIdea) {
|
|
404
|
+
console.log(`[extract] saasIdea=${args.saasIdea.slice(0, 80)}${args.saasIdea.length > 80 ? "…" : ""}`);
|
|
405
|
+
}
|
|
312
406
|
console.log("");
|
|
313
407
|
|
|
314
408
|
mkdirSync(outDir, { recursive: true });
|
|
@@ -316,6 +410,7 @@ async function main(): Promise<void> {
|
|
|
316
410
|
const limiter = new RateLimiter(args.rateLimitPerMinute);
|
|
317
411
|
const captures: SiteCapture[] = [];
|
|
318
412
|
const rawList: RawTokens[] = [];
|
|
413
|
+
const layoutsByHost = new Map<string, SiteLayout>();
|
|
319
414
|
|
|
320
415
|
let browser: Browser | null = null;
|
|
321
416
|
try {
|
|
@@ -333,7 +428,6 @@ async function main(): Promise<void> {
|
|
|
333
428
|
host,
|
|
334
429
|
capturedAt: new Date().toISOString(),
|
|
335
430
|
screenshotPath: "",
|
|
336
|
-
rawTokensPath: "",
|
|
337
431
|
status: "skipped",
|
|
338
432
|
reason: "robots.txt",
|
|
339
433
|
});
|
|
@@ -342,11 +436,16 @@ async function main(): Promise<void> {
|
|
|
342
436
|
}
|
|
343
437
|
|
|
344
438
|
await limiter.wait(host);
|
|
345
|
-
const result = await captureOne(browser, url, args.viewport, outDir
|
|
439
|
+
const result = await captureOne(browser, url, args.viewport, outDir, {
|
|
440
|
+
skipAssetDownload: args.skipAssetDownload,
|
|
441
|
+
});
|
|
346
442
|
if (!result) continue;
|
|
347
443
|
captures.push(result.capture);
|
|
348
444
|
if (result.capture.status === "ok") {
|
|
349
445
|
rawList.push(result.raw);
|
|
446
|
+
if (result.layout) {
|
|
447
|
+
layoutsByHost.set(host, result.layout);
|
|
448
|
+
}
|
|
350
449
|
const tag = result.layout ? "mirror" : "tokens-only";
|
|
351
450
|
const sectionCount = result.layout?.sections.length ?? 0;
|
|
352
451
|
console.log(
|
|
@@ -379,13 +478,16 @@ async function main(): Promise<void> {
|
|
|
379
478
|
outputDir: outDir,
|
|
380
479
|
captures,
|
|
381
480
|
designSystem,
|
|
481
|
+
...(args.saasIdea ? { saasIdea: args.saasIdea } : {}),
|
|
382
482
|
};
|
|
383
483
|
|
|
384
484
|
const written = emitAll(designSystem, run);
|
|
485
|
+
const researchWritten = emitClonerResearch(run, layoutsByHost);
|
|
385
486
|
writeFileSync(join(outDir, "run.json"), JSON.stringify(run, null, 2));
|
|
386
487
|
console.log("");
|
|
387
488
|
console.log("[extract] wrote:");
|
|
388
489
|
for (const f of written) console.log(` → ${f}`);
|
|
490
|
+
for (const f of researchWritten) console.log(` → ${f}`);
|
|
389
491
|
console.log(` → ${join(outDir, "run.json")}`);
|
|
390
492
|
const mirrorDirs = captures.filter((c) => c.mirrorDir).map((c) => c.mirrorDir!);
|
|
391
493
|
const referenceDirs = captures.filter((c) => c.referenceDir).map((c) => c.referenceDir!);
|
|
@@ -413,6 +515,8 @@ async function main(): Promise<void> {
|
|
|
413
515
|
);
|
|
414
516
|
}
|
|
415
517
|
console.log(`[extract] AI handoff: ${join(outDir, "FOR_AI.md")}`);
|
|
518
|
+
console.log(`[extract] clone-style research: ${join(outDir, "docs", "research", "README.md")}`);
|
|
519
|
+
console.log(`[extract] design references + downloads: ${join(outDir, "docs", "design-references")} · downloaded_assets/`);
|
|
416
520
|
}
|
|
417
521
|
|
|
418
522
|
function makeRunId(startedAt: string, name: string | undefined): string {
|
|
@@ -408,9 +408,12 @@ function emitGrid(s: SectionLayout, labelId: string): string {
|
|
|
408
408
|
const cardHeading: SlotKind =
|
|
409
409
|
(slots["heading-2"] ?? 0) >= cols ? "heading-2" : "heading-3";
|
|
410
410
|
|
|
411
|
+
const cardCount = Math.max(cols, Math.min(Math.max(slots["heading-3"] ?? 0, cols * 2), 12));
|
|
412
|
+
const cards = Array.from({ length: cardCount }, (_, i) => i);
|
|
413
|
+
|
|
411
414
|
const grid = [
|
|
412
415
|
`<Stagger as="ul" className=${JSON.stringify(`mt-12 grid gap-6 md:grid-cols-${cols}`)}>`,
|
|
413
|
-
...cards.map((
|
|
416
|
+
...cards.map(() =>
|
|
414
417
|
[
|
|
415
418
|
' <StaggerItem as="li" className="flex flex-col gap-3 rounded-lg border border-border bg-card p-6">',
|
|
416
419
|
' <Sparkle className="size-10 text-[var(--mirror-primary)]" weight="duotone" aria-hidden />',
|
|
@@ -293,6 +293,9 @@ export interface SiteCapture {
|
|
|
293
293
|
host: string;
|
|
294
294
|
capturedAt: string;
|
|
295
295
|
screenshotPath: string;
|
|
296
|
+
rawTokensPath?: string;
|
|
297
|
+
/** Path to crawled layout JSON when DOM crawl succeeded. */
|
|
298
|
+
layoutPath?: string;
|
|
296
299
|
/** Verbatim HTML + copy + media listing for AI reference. */
|
|
297
300
|
referenceDir?: string;
|
|
298
301
|
/** Path to the per-site mirror page directory, if emission succeeded. */
|
|
@@ -308,4 +311,6 @@ export interface ExtractionRun {
|
|
|
308
311
|
outputDir: string;
|
|
309
312
|
captures: SiteCapture[];
|
|
310
313
|
designSystem: DesignSystem | null;
|
|
314
|
+
/** Product narrative from CLI/env — drives FOR_AI.md and docs/research/. */
|
|
315
|
+
saasIdea?: string;
|
|
311
316
|
}
|