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.
@@ -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";
@@ -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
- "launchframe/0.1 (+https://github.com/evangruhlkey/launchframe; design-token research; respects robots.txt)";
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 === "--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 =
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] (from any folder)",
99
- " npm run extract -- <url> [<url> ...] [options] (from this repo)",
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
- " --out <dir> Output directory (default: output/<runId>)",
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 {
@@ -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
  }