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.
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * `extract` — the headline command.
3
3
  *
4
- * npm run extract -- https://site-a.com https://site-b.com https://site-c.com
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 === "--out") args.outDir = argv[++i]!;
69
- else if (a === "--name") args.runName = argv[++i];
70
- else if (a === "--no-robots") args.respectRobots = false;
71
- else if (a === "--rate") args.rateLimitPerMinute = parseInt(argv[++i]!, 10);
72
- else if (a === "--width") args.viewport.width = parseInt(argv[++i]!, 10);
73
- else if (a === "--height") args.viewport.height = parseInt(argv[++i]!, 10);
74
- else if (a === "--help" || a === "-h") {
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
- } else if (a.startsWith("http://") || a.startsWith("https://")) {
78
- args.urls.push(a);
79
- } else if (a.startsWith("--")) {
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
- } else {
83
- console.error(`Unrecognized argument: ${a}`);
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
- if (args.urls.length === 0) {
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] (from any folder)",
99
- " npm run extract -- <url> [<url> ...] [options] (from this repo)",
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
- " --out <dir> Output directory (default: output/<runId>)",
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 {
@@ -0,0 +1,5 @@
1
+ /** Filesystem-safe slug for a URL host (cross-package helpers). */
2
+ export function hostSlug(host: string): string {
3
+ const s = host.replace(/[^a-z0-9.-]+/gi, "-").replace(/^-|-$/g, "").toLowerCase();
4
+ return s.length > 0 ? s : "site";
5
+ }
@@ -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((c) =>
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
  }