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.
@@ -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,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 === "--out") args.outDir = argv[++i]!;
68
- else if (a === "--name") args.runName = argv[++i];
69
- else if (a === "--no-robots") args.respectRobots = false;
70
- else if (a === "--rate") args.rateLimitPerMinute = parseInt(argv[++i]!, 10);
71
- else if (a === "--width") args.viewport.width = parseInt(argv[++i]!, 10);
72
- else if (a === "--height") args.viewport.height = parseInt(argv[++i]!, 10);
73
- 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") {
74
120
  printHelp();
75
121
  process.exit(0);
76
- } else if (a.startsWith("http://") || a.startsWith("https://")) {
77
- args.urls.push(a);
78
- } else if (a.startsWith("--")) {
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
- } else {
82
- 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.");
83
143
  process.exit(2);
84
144
  }
145
+ args.saasIdea = positional[0];
85
146
  }
86
- 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) {
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] (from any folder)",
98
- " 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)",
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
- " --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.",
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: "networkidle", timeout: 30_000 });
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 {
@@ -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
  }