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
|
@@ -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";
|
|
@@ -40,11 +46,11 @@ import type { ExtractionRun, RawTokens, SiteCapture, SiteLayout } from "./types.
|
|
|
40
46
|
|
|
41
47
|
const __filename = fileURLToPath(import.meta.url);
|
|
42
48
|
const __dirname = dirname(__filename);
|
|
43
|
-
/** Writes under the user's cwd so `npx launchframe` from any folder works. */
|
|
49
|
+
/** Writes under the user's cwd so `npx launchframe` / `npx landingfram` from any folder works. */
|
|
44
50
|
const DEFAULT_OUTPUT_ROOT = join(process.cwd(), "output");
|
|
45
51
|
|
|
46
52
|
const USER_AGENT =
|
|
47
|
-
"
|
|
53
|
+
"landingfram/0.1 (+https://github.com/evangruhlkey/launchframe; design-token research; respects robots.txt)";
|
|
48
54
|
|
|
49
55
|
interface CliArgs {
|
|
50
56
|
urls: string[];
|
|
@@ -53,38 +59,99 @@ interface CliArgs {
|
|
|
53
59
|
respectRobots: boolean;
|
|
54
60
|
rateLimitPerMinute: number;
|
|
55
61
|
runName?: string;
|
|
62
|
+
/** SaaS product narrative from `--idea`, a positional string, or LANDINGFRAM_SAAS_IDEA / 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 =
|
|
149
|
+
process.env.LANDINGFRAM_SAAS_IDEA?.trim() || process.env.LAUNCHFRAME_SAAS_IDEA?.trim();
|
|
150
|
+
if (envIdea && !args.saasIdea) {
|
|
151
|
+
args.saasIdea = envIdea;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (urls.length === 0) {
|
|
88
155
|
printHelp();
|
|
89
156
|
process.exit(2);
|
|
90
157
|
}
|
|
@@ -95,8 +162,9 @@ function printHelp(): void {
|
|
|
95
162
|
console.log(
|
|
96
163
|
[
|
|
97
164
|
"Usage:",
|
|
98
|
-
" npx launchframe <url> [<url> ...] [options]
|
|
99
|
-
"
|
|
165
|
+
" npx launchframe@latest <url> [\"SaaS idea\"] [<url> ...] [options]",
|
|
166
|
+
" npx launchframe@latest <url> --idea \"SaaS idea\" [options]",
|
|
167
|
+
" npm run extract -- <url> [\"idea\"] [...] [options] (from this repo)",
|
|
100
168
|
"",
|
|
101
169
|
"Writes to ./output/<runId>/ in your current working directory unless",
|
|
102
170
|
"you pass --out.",
|
|
@@ -114,8 +182,14 @@ function printHelp(): void {
|
|
|
114
182
|
"After every URL, a drop-in shadcn-compatible design system is",
|
|
115
183
|
"synthesized from the aggregated tokens and written to output/<runId>/.",
|
|
116
184
|
"",
|
|
185
|
+
" Your SaaS idea is stored in run.json, FOR_AI.md, and docs/research/ for agents.",
|
|
186
|
+
"",
|
|
117
187
|
"Options:",
|
|
118
|
-
" --
|
|
188
|
+
" --idea <text> SaaS product narrative (alternative to one positional string)",
|
|
189
|
+
" --no-download Skip downloading binaries listed in reference/*/media.json",
|
|
190
|
+
" --out <dir> Run directory (default: ./output/<runId>). Use e.g.",
|
|
191
|
+
" ./my-next-app/extraction-acme to mirror template-style",
|
|
192
|
+
" docs/ placement inside a consumer repo.",
|
|
119
193
|
" --name <slug> Human-friendly slug used in the runId",
|
|
120
194
|
" --no-robots Skip robots.txt check (not recommended)",
|
|
121
195
|
" --rate <per-min> Per-domain rate limit, default 15",
|
|
@@ -198,6 +272,7 @@ async function captureOne(
|
|
|
198
272
|
url: string,
|
|
199
273
|
viewport: { width: number; height: number },
|
|
200
274
|
outDir: string,
|
|
275
|
+
opts: { skipAssetDownload: boolean },
|
|
201
276
|
): Promise<{ raw: RawTokens; layout: SiteLayout | null; capture: SiteCapture } | null> {
|
|
202
277
|
const host = new URL(url).host;
|
|
203
278
|
const stamp = `${host}.png`;
|
|
@@ -256,6 +331,23 @@ async function captureOne(
|
|
|
256
331
|
console.warn(` ! layout crawl failed for ${url}: ${(err as Error).message}`);
|
|
257
332
|
}
|
|
258
333
|
|
|
334
|
+
try {
|
|
335
|
+
const n = (
|
|
336
|
+
await runAutomatedClonePass({
|
|
337
|
+
page,
|
|
338
|
+
host,
|
|
339
|
+
outDir,
|
|
340
|
+
primaryViewport: viewport,
|
|
341
|
+
referenceDir: referenceWritten.length > 0 ? referenceDir : undefined,
|
|
342
|
+
userAgent: USER_AGENT,
|
|
343
|
+
downloadAssets: !opts.skipAssetDownload,
|
|
344
|
+
})
|
|
345
|
+
).length;
|
|
346
|
+
if (n > 0) console.log(` · automated recon (${n} files under docs/design-references & downloads)`);
|
|
347
|
+
} catch (err) {
|
|
348
|
+
console.warn(` ! automated recon failed for ${url}: ${(err as Error).message}`);
|
|
349
|
+
}
|
|
350
|
+
|
|
259
351
|
const capture: SiteCapture = {
|
|
260
352
|
url,
|
|
261
353
|
host,
|
|
@@ -277,7 +369,6 @@ async function captureOne(
|
|
|
277
369
|
host,
|
|
278
370
|
capturedAt: new Date().toISOString(),
|
|
279
371
|
screenshotPath: "",
|
|
280
|
-
rawTokensPath: "",
|
|
281
372
|
status: "failed",
|
|
282
373
|
reason: (err as Error).message,
|
|
283
374
|
},
|
|
@@ -310,6 +401,9 @@ async function main(): Promise<void> {
|
|
|
310
401
|
console.log(`[extract] runId=${runId}`);
|
|
311
402
|
console.log(`[extract] urls=${args.urls.length} viewport=${args.viewport.width}x${args.viewport.height}`);
|
|
312
403
|
console.log(`[extract] output=${outDir}`);
|
|
404
|
+
if (args.saasIdea) {
|
|
405
|
+
console.log(`[extract] saasIdea=${args.saasIdea.slice(0, 80)}${args.saasIdea.length > 80 ? "…" : ""}`);
|
|
406
|
+
}
|
|
313
407
|
console.log("");
|
|
314
408
|
|
|
315
409
|
mkdirSync(outDir, { recursive: true });
|
|
@@ -317,6 +411,7 @@ async function main(): Promise<void> {
|
|
|
317
411
|
const limiter = new RateLimiter(args.rateLimitPerMinute);
|
|
318
412
|
const captures: SiteCapture[] = [];
|
|
319
413
|
const rawList: RawTokens[] = [];
|
|
414
|
+
const layoutsByHost = new Map<string, SiteLayout>();
|
|
320
415
|
|
|
321
416
|
let browser: Browser | null = null;
|
|
322
417
|
try {
|
|
@@ -334,7 +429,6 @@ async function main(): Promise<void> {
|
|
|
334
429
|
host,
|
|
335
430
|
capturedAt: new Date().toISOString(),
|
|
336
431
|
screenshotPath: "",
|
|
337
|
-
rawTokensPath: "",
|
|
338
432
|
status: "skipped",
|
|
339
433
|
reason: "robots.txt",
|
|
340
434
|
});
|
|
@@ -343,11 +437,16 @@ async function main(): Promise<void> {
|
|
|
343
437
|
}
|
|
344
438
|
|
|
345
439
|
await limiter.wait(host);
|
|
346
|
-
const result = await captureOne(browser, url, args.viewport, outDir
|
|
440
|
+
const result = await captureOne(browser, url, args.viewport, outDir, {
|
|
441
|
+
skipAssetDownload: args.skipAssetDownload,
|
|
442
|
+
});
|
|
347
443
|
if (!result) continue;
|
|
348
444
|
captures.push(result.capture);
|
|
349
445
|
if (result.capture.status === "ok") {
|
|
350
446
|
rawList.push(result.raw);
|
|
447
|
+
if (result.layout) {
|
|
448
|
+
layoutsByHost.set(host, result.layout);
|
|
449
|
+
}
|
|
351
450
|
const tag = result.layout ? "mirror" : "tokens-only";
|
|
352
451
|
const sectionCount = result.layout?.sections.length ?? 0;
|
|
353
452
|
console.log(
|
|
@@ -380,13 +479,16 @@ async function main(): Promise<void> {
|
|
|
380
479
|
outputDir: outDir,
|
|
381
480
|
captures,
|
|
382
481
|
designSystem,
|
|
482
|
+
...(args.saasIdea ? { saasIdea: args.saasIdea } : {}),
|
|
383
483
|
};
|
|
384
484
|
|
|
385
485
|
const written = emitAll(designSystem, run);
|
|
486
|
+
const researchWritten = emitClonerResearch(run, layoutsByHost);
|
|
386
487
|
writeFileSync(join(outDir, "run.json"), JSON.stringify(run, null, 2));
|
|
387
488
|
console.log("");
|
|
388
489
|
console.log("[extract] wrote:");
|
|
389
490
|
for (const f of written) console.log(` → ${f}`);
|
|
491
|
+
for (const f of researchWritten) console.log(` → ${f}`);
|
|
390
492
|
console.log(` → ${join(outDir, "run.json")}`);
|
|
391
493
|
const mirrorDirs = captures.filter((c) => c.mirrorDir).map((c) => c.mirrorDir!);
|
|
392
494
|
const referenceDirs = captures.filter((c) => c.referenceDir).map((c) => c.referenceDir!);
|
|
@@ -414,6 +516,8 @@ async function main(): Promise<void> {
|
|
|
414
516
|
);
|
|
415
517
|
}
|
|
416
518
|
console.log(`[extract] AI handoff: ${join(outDir, "FOR_AI.md")}`);
|
|
519
|
+
console.log(`[extract] clone-style research: ${join(outDir, "docs", "research", "README.md")}`);
|
|
520
|
+
console.log(`[extract] design references + downloads: ${join(outDir, "docs", "design-references")} · downloaded_assets/`);
|
|
417
521
|
}
|
|
418
522
|
|
|
419
523
|
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
|
}
|