launchframe 0.1.11 → 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 +124 -21
- 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,7 +35,9 @@ 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";
|
|
36
42
|
import { emitMirror } from "./mirror-emit.js";
|
|
37
43
|
import { emitPageReference } from "./reference-dump.js";
|
|
@@ -53,38 +59,98 @@ interface CliArgs {
|
|
|
53
59
|
respectRobots: boolean;
|
|
54
60
|
rateLimitPerMinute: number;
|
|
55
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;
|
|
56
66
|
}
|
|
57
67
|
|
|
58
68
|
function parseArgs(argv: string[]): CliArgs {
|
|
69
|
+
const urls: string[] = [];
|
|
70
|
+
const positional: string[] = [];
|
|
59
71
|
const args: CliArgs = {
|
|
60
|
-
urls
|
|
72
|
+
urls,
|
|
61
73
|
outDir: "",
|
|
62
74
|
viewport: { width: 1440, height: 900 },
|
|
63
75
|
respectRobots: true,
|
|
64
76
|
rateLimitPerMinute: 15,
|
|
77
|
+
skipAssetDownload: false,
|
|
65
78
|
};
|
|
79
|
+
|
|
66
80
|
for (let i = 0; i < argv.length; i++) {
|
|
67
81
|
const a = argv[i]!;
|
|
68
|
-
if (a === "--
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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") {
|
|
75
120
|
printHelp();
|
|
76
121
|
process.exit(0);
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
|
|
122
|
+
}
|
|
123
|
+
if (a.startsWith("http://") || a.startsWith("https://")) {
|
|
124
|
+
urls.push(a);
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
if (a.startsWith("--")) {
|
|
80
128
|
console.error(`Unknown flag: ${a}`);
|
|
81
129
|
process.exit(2);
|
|
82
|
-
}
|
|
83
|
-
|
|
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.");
|
|
84
143
|
process.exit(2);
|
|
85
144
|
}
|
|
145
|
+
args.saasIdea = positional[0];
|
|
86
146
|
}
|
|
87
|
-
|
|
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) {
|
|
88
154
|
printHelp();
|
|
89
155
|
process.exit(2);
|
|
90
156
|
}
|
|
@@ -95,8 +161,9 @@ function printHelp(): void {
|
|
|
95
161
|
console.log(
|
|
96
162
|
[
|
|
97
163
|
"Usage:",
|
|
98
|
-
" npx launchframe <url> [<url> ...] [options]
|
|
99
|
-
"
|
|
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)",
|
|
100
167
|
"",
|
|
101
168
|
"Writes to ./output/<runId>/ in your current working directory unless",
|
|
102
169
|
"you pass --out.",
|
|
@@ -114,8 +181,14 @@ function printHelp(): void {
|
|
|
114
181
|
"After every URL, a drop-in shadcn-compatible design system is",
|
|
115
182
|
"synthesized from the aggregated tokens and written to output/<runId>/.",
|
|
116
183
|
"",
|
|
184
|
+
" Your SaaS idea is stored in run.json, FOR_AI.md, and docs/research/ for agents.",
|
|
185
|
+
"",
|
|
117
186
|
"Options:",
|
|
118
|
-
" --
|
|
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.",
|
|
119
192
|
" --name <slug> Human-friendly slug used in the runId",
|
|
120
193
|
" --no-robots Skip robots.txt check (not recommended)",
|
|
121
194
|
" --rate <per-min> Per-domain rate limit, default 15",
|
|
@@ -198,6 +271,7 @@ async function captureOne(
|
|
|
198
271
|
url: string,
|
|
199
272
|
viewport: { width: number; height: number },
|
|
200
273
|
outDir: string,
|
|
274
|
+
opts: { skipAssetDownload: boolean },
|
|
201
275
|
): Promise<{ raw: RawTokens; layout: SiteLayout | null; capture: SiteCapture } | null> {
|
|
202
276
|
const host = new URL(url).host;
|
|
203
277
|
const stamp = `${host}.png`;
|
|
@@ -256,6 +330,23 @@ async function captureOne(
|
|
|
256
330
|
console.warn(` ! layout crawl failed for ${url}: ${(err as Error).message}`);
|
|
257
331
|
}
|
|
258
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
|
+
|
|
259
350
|
const capture: SiteCapture = {
|
|
260
351
|
url,
|
|
261
352
|
host,
|
|
@@ -277,7 +368,6 @@ async function captureOne(
|
|
|
277
368
|
host,
|
|
278
369
|
capturedAt: new Date().toISOString(),
|
|
279
370
|
screenshotPath: "",
|
|
280
|
-
rawTokensPath: "",
|
|
281
371
|
status: "failed",
|
|
282
372
|
reason: (err as Error).message,
|
|
283
373
|
},
|
|
@@ -310,6 +400,9 @@ async function main(): Promise<void> {
|
|
|
310
400
|
console.log(`[extract] runId=${runId}`);
|
|
311
401
|
console.log(`[extract] urls=${args.urls.length} viewport=${args.viewport.width}x${args.viewport.height}`);
|
|
312
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
|
+
}
|
|
313
406
|
console.log("");
|
|
314
407
|
|
|
315
408
|
mkdirSync(outDir, { recursive: true });
|
|
@@ -317,6 +410,7 @@ async function main(): Promise<void> {
|
|
|
317
410
|
const limiter = new RateLimiter(args.rateLimitPerMinute);
|
|
318
411
|
const captures: SiteCapture[] = [];
|
|
319
412
|
const rawList: RawTokens[] = [];
|
|
413
|
+
const layoutsByHost = new Map<string, SiteLayout>();
|
|
320
414
|
|
|
321
415
|
let browser: Browser | null = null;
|
|
322
416
|
try {
|
|
@@ -334,7 +428,6 @@ async function main(): Promise<void> {
|
|
|
334
428
|
host,
|
|
335
429
|
capturedAt: new Date().toISOString(),
|
|
336
430
|
screenshotPath: "",
|
|
337
|
-
rawTokensPath: "",
|
|
338
431
|
status: "skipped",
|
|
339
432
|
reason: "robots.txt",
|
|
340
433
|
});
|
|
@@ -343,11 +436,16 @@ async function main(): Promise<void> {
|
|
|
343
436
|
}
|
|
344
437
|
|
|
345
438
|
await limiter.wait(host);
|
|
346
|
-
const result = await captureOne(browser, url, args.viewport, outDir
|
|
439
|
+
const result = await captureOne(browser, url, args.viewport, outDir, {
|
|
440
|
+
skipAssetDownload: args.skipAssetDownload,
|
|
441
|
+
});
|
|
347
442
|
if (!result) continue;
|
|
348
443
|
captures.push(result.capture);
|
|
349
444
|
if (result.capture.status === "ok") {
|
|
350
445
|
rawList.push(result.raw);
|
|
446
|
+
if (result.layout) {
|
|
447
|
+
layoutsByHost.set(host, result.layout);
|
|
448
|
+
}
|
|
351
449
|
const tag = result.layout ? "mirror" : "tokens-only";
|
|
352
450
|
const sectionCount = result.layout?.sections.length ?? 0;
|
|
353
451
|
console.log(
|
|
@@ -380,13 +478,16 @@ async function main(): Promise<void> {
|
|
|
380
478
|
outputDir: outDir,
|
|
381
479
|
captures,
|
|
382
480
|
designSystem,
|
|
481
|
+
...(args.saasIdea ? { saasIdea: args.saasIdea } : {}),
|
|
383
482
|
};
|
|
384
483
|
|
|
385
484
|
const written = emitAll(designSystem, run);
|
|
485
|
+
const researchWritten = emitClonerResearch(run, layoutsByHost);
|
|
386
486
|
writeFileSync(join(outDir, "run.json"), JSON.stringify(run, null, 2));
|
|
387
487
|
console.log("");
|
|
388
488
|
console.log("[extract] wrote:");
|
|
389
489
|
for (const f of written) console.log(` → ${f}`);
|
|
490
|
+
for (const f of researchWritten) console.log(` → ${f}`);
|
|
390
491
|
console.log(` → ${join(outDir, "run.json")}`);
|
|
391
492
|
const mirrorDirs = captures.filter((c) => c.mirrorDir).map((c) => c.mirrorDir!);
|
|
392
493
|
const referenceDirs = captures.filter((c) => c.referenceDir).map((c) => c.referenceDir!);
|
|
@@ -414,6 +515,8 @@ async function main(): Promise<void> {
|
|
|
414
515
|
);
|
|
415
516
|
}
|
|
416
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/`);
|
|
417
520
|
}
|
|
418
521
|
|
|
419
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
|
}
|