launchframe 0.2.0 → 0.2.1

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.
Files changed (72) hide show
  1. package/README.md +143 -183
  2. package/bin/launchframe.mjs +234 -30
  3. package/package.json +52 -67
  4. package/template/.aider.conf.yml +3 -0
  5. package/template/.amazonq/cli-agents/clone-website.json +9 -0
  6. package/template/.amazonq/rules/project.md +156 -0
  7. package/template/.augment/commands/clone-website.md +516 -0
  8. package/template/.claude/skills/clone-website/SKILL.md +515 -0
  9. package/template/.clinerules +156 -0
  10. package/template/.codex/skills/clone-website/SKILL.md +515 -0
  11. package/template/.continue/commands/clone-website.md +517 -0
  12. package/template/.continue/rules/project.md +160 -0
  13. package/template/.cursor/commands/clone-website.md +512 -0
  14. package/template/.cursor/rules/project.mdc +7 -0
  15. package/template/.dockerignore +60 -0
  16. package/template/.gemini/commands/clone-website.toml +518 -0
  17. package/template/.gitattributes +9 -0
  18. package/template/.github/ISSUE_TEMPLATE/bug_report.yml +86 -0
  19. package/template/.github/ISSUE_TEMPLATE/config.yml +5 -0
  20. package/template/.github/ISSUE_TEMPLATE/feature_request.yml +50 -0
  21. package/template/.github/PULL_REQUEST_TEMPLATE.md +19 -0
  22. package/template/.github/copilot-instructions.md +156 -0
  23. package/template/.github/copilot-setup-steps.yml +3 -0
  24. package/template/.github/skills/clone-website/SKILL.md +515 -0
  25. package/template/.github/workflows/ci.yml +36 -0
  26. package/template/.nvmrc +1 -0
  27. package/template/.opencode/commands/clone-website.md +515 -0
  28. package/template/.windsurf/workflows/clone-website.md +512 -0
  29. package/template/.windsurfrules +2 -0
  30. package/template/AGENTS.md +74 -0
  31. package/template/CHANGELOG.md +80 -0
  32. package/template/CLAUDE.md +1 -0
  33. package/template/Dockerfile +114 -0
  34. package/template/Dockerfile.dev +15 -0
  35. package/template/GEMINI.md +1 -0
  36. package/template/README.md +129 -0
  37. package/template/components.json +25 -0
  38. package/template/docker-compose.yml +53 -0
  39. package/template/docs/design-references/.gitkeep +0 -0
  40. package/template/docs/design-references/comparison.png +0 -0
  41. package/template/docs/research/INSPECTION_GUIDE.md +80 -0
  42. package/template/eslint.config.mjs +18 -0
  43. package/template/next.config.ts +8 -0
  44. package/template/package.json +59 -0
  45. package/template/postcss.config.mjs +7 -0
  46. package/template/public/images/.gitkeep +0 -0
  47. package/template/public/seo/.gitkeep +0 -0
  48. package/template/public/videos/.gitkeep +0 -0
  49. package/template/scripts/.gitkeep +0 -0
  50. package/template/scripts/sync-agent-rules.sh +88 -0
  51. package/template/scripts/sync-skills.mjs +111 -0
  52. package/template/src/app/favicon.ico +0 -0
  53. package/template/src/app/globals.css +130 -0
  54. package/template/src/app/layout.tsx +33 -0
  55. package/template/src/app/page.tsx +9 -0
  56. package/template/src/components/ui/button.tsx +60 -0
  57. package/template/src/hooks/.gitkeep +0 -0
  58. package/template/src/lib/utils.ts +6 -0
  59. package/template/src/types/.gitkeep +0 -0
  60. package/template/tsconfig.json +34 -0
  61. package/packages/extract/automated-clone-pass.ts +0 -353
  62. package/packages/extract/browser-extract.ts +0 -237
  63. package/packages/extract/cloner-research-emit.ts +0 -270
  64. package/packages/extract/dom-crawler.ts +0 -521
  65. package/packages/extract/emit.ts +0 -553
  66. package/packages/extract/extract.ts +0 -548
  67. package/packages/extract/host-slug.ts +0 -5
  68. package/packages/extract/mirror-emit.ts +0 -620
  69. package/packages/extract/package.json +0 -13
  70. package/packages/extract/reference-dump.ts +0 -431
  71. package/packages/extract/synthesize.ts +0 -551
  72. package/packages/extract/types.ts +0 -316
@@ -1,548 +0,0 @@
1
- /**
2
- * `extract` — the headline command.
3
- *
4
- * npx launchframe@latest https://site-a.com "Your SaaS idea"
5
- * npm run extract -- https://site-a.com "idea" https://site-b.com
6
- *
7
- * For each URL: open in Chromium, screenshot, harvest computed design
8
- * tokens via `browser-extract.ts`, and crawl the rendered DOM into a
9
- * typed `SiteLayout` model via `dom-crawler.ts`. After all sites:
10
- * - Synthesize a drop-in shadcn-compatible design system from the
11
- * aggregated tokens.
12
- * - Emit a per-site **layout mirror**: a Next.js page that reconstructs
13
- * the source's section structure from typed primitives, with
14
- * `<TextSlot>` / `<MediaSlot>` placeholders for the user's copy and
15
- * brand assets.
16
- *
17
- * Output goes to `output/<runId>/`.
18
- *
19
- * Operational defaults (configurable via flags):
20
- * - Honor robots.txt unless `--no-robots` is passed.
21
- * - Per-domain rate limit defaults to 15 req/min (`--rate <n>`).
22
- * - The crawler extracts a structured representation (section tree,
23
- * computed style tokens, content kinds) and writes a verbatim
24
- * `reference/<host>/` bundle (HTML, DOM tree JSON, outlines, visible text,
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).
29
- */
30
-
31
- import { mkdirSync, writeFileSync } from "node:fs";
32
- import { dirname, join } from "node:path";
33
- import { fileURLToPath, pathToFileURL } from "node:url";
34
-
35
- import { chromium, type Browser } from "playwright";
36
-
37
- import { harvestTokens } from "./browser-extract.js";
38
- import { runAutomatedClonePass } from "./automated-clone-pass.js";
39
- import { crawlLayout } from "./dom-crawler.js";
40
- import { emitClonerResearch } from "./cloner-research-emit.js";
41
- import { emitAll } from "./emit.js";
42
- import { emitMirror } from "./mirror-emit.js";
43
- import { emitPageReference } from "./reference-dump.js";
44
- import { synthesize } from "./synthesize.js";
45
- import type { ExtractionRun, RawTokens, SiteCapture, SiteLayout } from "./types.js";
46
-
47
- const __filename = fileURLToPath(import.meta.url);
48
- const __dirname = dirname(__filename);
49
- /** Writes under the user's cwd so `npx launchframe` / `npx landingfram` from any folder works. */
50
- const DEFAULT_OUTPUT_ROOT = join(process.cwd(), "output");
51
-
52
- const USER_AGENT =
53
- "landingfram/0.1 (+https://github.com/evangruhlkey/launchframe; design-token research; respects robots.txt)";
54
-
55
- interface CliArgs {
56
- urls: string[];
57
- outDir: string;
58
- viewport: { width: number; height: number };
59
- respectRobots: boolean;
60
- rateLimitPerMinute: number;
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;
66
- }
67
-
68
- function parseArgs(argv: string[]): CliArgs {
69
- const urls: string[] = [];
70
- const positional: string[] = [];
71
- const args: CliArgs = {
72
- urls,
73
- outDir: "",
74
- viewport: { width: 1440, height: 900 },
75
- respectRobots: true,
76
- rateLimitPerMinute: 15,
77
- skipAssetDownload: false,
78
- };
79
-
80
- for (let i = 0; i < argv.length; i++) {
81
- const a = argv[i]!;
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") {
120
- printHelp();
121
- process.exit(0);
122
- }
123
- if (a.startsWith("http://") || a.startsWith("https://")) {
124
- urls.push(a);
125
- continue;
126
- }
127
- if (a.startsWith("--")) {
128
- console.error(`Unknown flag: ${a}`);
129
- process.exit(2);
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.");
143
- process.exit(2);
144
- }
145
- args.saasIdea = positional[0];
146
- }
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) {
155
- printHelp();
156
- process.exit(2);
157
- }
158
- return args;
159
- }
160
-
161
- function printHelp(): void {
162
- console.log(
163
- [
164
- "Usage:",
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)",
168
- "",
169
- "Writes to ./output/<runId>/ in your current working directory unless",
170
- "you pass --out.",
171
- "",
172
- "For each URL the CLI:",
173
- " 1. Renders the page at a desktop viewport in headless Chromium.",
174
- " 2. Captures a full-page screenshot and harvests computed design tokens",
175
- " (colors, type, spacing, radius, shadow) → raw/<host>.tokens.json.",
176
- " 3. Writes a verbatim reference bundle → reference/<host>/ (page.html,",
177
- " dom-structure.json, structure-outline.txt, visible-text.json/.txt,",
178
- " media.json, meta.json, FOR_AI_REFERENCE.md).",
179
- " 4. Crawls the DOM into SiteLayout → raw/<host>.layout.json and emits",
180
- " mirror/<host>/page.tsx (Framer Motion + Phosphor + image/video slots).",
181
- "",
182
- "After every URL, a drop-in shadcn-compatible design system is",
183
- "synthesized from the aggregated tokens and written to output/<runId>/.",
184
- "",
185
- " Your SaaS idea is stored in run.json, FOR_AI.md, and docs/research/ for agents.",
186
- "",
187
- "Options:",
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.",
193
- " --name <slug> Human-friendly slug used in the runId",
194
- " --no-robots Skip robots.txt check (not recommended)",
195
- " --rate <per-min> Per-domain rate limit, default 15",
196
- " --width <px> Viewport width, default 1440",
197
- " --height <px> Viewport height, default 900",
198
- " --help Show this help",
199
- ].join("\n"),
200
- );
201
- }
202
-
203
- /* -------------------------------------------------------------------------- */
204
- /* robots.txt */
205
- /* -------------------------------------------------------------------------- */
206
-
207
- async function isAllowedByRobots(url: string): Promise<boolean> {
208
- try {
209
- const u = new URL(url);
210
- const res = await fetch(`${u.origin}/robots.txt`, {
211
- headers: { "User-Agent": USER_AGENT },
212
- signal: AbortSignal.timeout(8_000),
213
- });
214
- if (!res.ok) return true;
215
- const body = await res.text();
216
- return checkRobots(body, u.pathname);
217
- } catch {
218
- return true;
219
- }
220
- }
221
-
222
- function checkRobots(body: string, pathname: string): boolean {
223
- const lines = body.split(/\r?\n/);
224
- let inStarBlock = false;
225
- const disallow: string[] = [];
226
- const allow: string[] = [];
227
- for (const raw of lines) {
228
- const line = raw.split("#")[0]!.trim();
229
- if (!line) continue;
230
- const idx = line.indexOf(":");
231
- if (idx < 0) continue;
232
- const key = line.slice(0, idx).toLowerCase().trim();
233
- const value = line.slice(idx + 1).trim();
234
- if (key === "user-agent") inStarBlock = value === "*";
235
- else if (inStarBlock && key === "disallow" && value) disallow.push(value);
236
- else if (inStarBlock && key === "allow" && value) allow.push(value);
237
- }
238
- const len = (patterns: string[]) =>
239
- patterns.reduce((m, p) => (pathname.startsWith(p) ? Math.max(m, p.length) : m), -1);
240
- const a = len(allow);
241
- const d = len(disallow);
242
- if (d < 0) return true;
243
- return a >= d;
244
- }
245
-
246
- /* -------------------------------------------------------------------------- */
247
- /* Rate limiter */
248
- /* -------------------------------------------------------------------------- */
249
-
250
- class RateLimiter {
251
- private readonly intervalMs: number;
252
- private readonly lastByHost = new Map<string, number>();
253
- constructor(perMinute: number) {
254
- this.intervalMs = Math.ceil(60_000 / Math.max(1, perMinute));
255
- }
256
- async wait(host: string): Promise<void> {
257
- const last = this.lastByHost.get(host) ?? 0;
258
- const elapsed = Date.now() - last;
259
- if (elapsed < this.intervalMs) {
260
- await new Promise((r) => setTimeout(r, this.intervalMs - elapsed));
261
- }
262
- this.lastByHost.set(host, Date.now());
263
- }
264
- }
265
-
266
- /* -------------------------------------------------------------------------- */
267
- /* Pipeline */
268
- /* -------------------------------------------------------------------------- */
269
-
270
- async function captureOne(
271
- browser: Browser,
272
- url: string,
273
- viewport: { width: number; height: number },
274
- outDir: string,
275
- opts: { skipAssetDownload: boolean },
276
- ): Promise<{ raw: RawTokens; layout: SiteLayout | null; capture: SiteCapture } | null> {
277
- const host = new URL(url).host;
278
- const stamp = `${host}.png`;
279
- const screenshotPath = join(outDir, "screenshots", stamp);
280
- const rawPath = join(outDir, "raw", `${host}.tokens.json`);
281
- const layoutPath = join(outDir, "raw", `${host}.layout.json`);
282
- const mirrorDir = join(outDir, "mirror", host);
283
- const referenceDir = join(outDir, "reference", host);
284
-
285
- const ctx = await browser.newContext({
286
- userAgent: USER_AGENT,
287
- viewport,
288
- deviceScaleFactor: 2,
289
- reducedMotion: "reduce",
290
- });
291
- const page = await ctx.newPage();
292
- try {
293
- const response = await page.goto(url, { waitUntil: "load", timeout: 60_000 });
294
- if (!response || response.status() >= 400) {
295
- throw new Error(`HTTP ${response?.status() ?? "unknown"}`);
296
- }
297
-
298
- await page.evaluate(() => {
299
- const style = document.createElement("style");
300
- style.textContent = `*, *::before, *::after {
301
- animation: none !important;
302
- transition: none !important;
303
- scroll-behavior: auto !important;
304
- }`;
305
- document.head.appendChild(style);
306
- });
307
- await page.waitForTimeout(400);
308
-
309
- mkdirSync(dirname(screenshotPath), { recursive: true });
310
- await page.screenshot({ path: screenshotPath, fullPage: true, type: "png" });
311
-
312
- const raw = await harvestTokens(page, url, viewport);
313
- mkdirSync(dirname(rawPath), { recursive: true });
314
- writeFileSync(rawPath, JSON.stringify(raw, null, 2));
315
-
316
- let referenceWritten: string[] = [];
317
- try {
318
- referenceWritten = await emitPageReference(page, url, referenceDir, viewport);
319
- } catch (err) {
320
- console.warn(` ! reference dump failed for ${url}: ${(err as Error).message}`);
321
- }
322
-
323
- let layout: SiteLayout | null = null;
324
- let mirrorWritten: string[] = [];
325
- try {
326
- layout = await crawlLayout(page, url, viewport);
327
- mkdirSync(dirname(layoutPath), { recursive: true });
328
- writeFileSync(layoutPath, JSON.stringify(layout, null, 2));
329
- mirrorWritten = emitMirror(layout, mirrorDir);
330
- } catch (err) {
331
- console.warn(` ! layout crawl failed for ${url}: ${(err as Error).message}`);
332
- }
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
-
351
- const capture: SiteCapture = {
352
- url,
353
- host,
354
- capturedAt: raw.capturedAt,
355
- screenshotPath,
356
- rawTokensPath: rawPath,
357
- ...(referenceWritten.length > 0 ? { referenceDir } : {}),
358
- ...(layout ? { layoutPath } : {}),
359
- ...(mirrorWritten.length > 0 ? { mirrorDir } : {}),
360
- status: "ok",
361
- };
362
- return { raw, layout, capture };
363
- } catch (err) {
364
- return {
365
- raw: emptyRaw(url, viewport),
366
- layout: null,
367
- capture: {
368
- url,
369
- host,
370
- capturedAt: new Date().toISOString(),
371
- screenshotPath: "",
372
- status: "failed",
373
- reason: (err as Error).message,
374
- },
375
- };
376
- } finally {
377
- await ctx.close();
378
- }
379
- }
380
-
381
- function emptyRaw(url: string, viewport: { width: number; height: number }): RawTokens {
382
- return {
383
- url,
384
- capturedAt: new Date().toISOString(),
385
- viewport,
386
- colors: [],
387
- typography: [],
388
- spacing: [],
389
- radii: [],
390
- shadows: [],
391
- dominantContainerPx: null,
392
- };
393
- }
394
-
395
- async function main(): Promise<void> {
396
- const args = parseArgs(process.argv.slice(2));
397
- const startedAt = new Date().toISOString();
398
- const runId = makeRunId(startedAt, args.runName);
399
- const outDir = args.outDir || join(DEFAULT_OUTPUT_ROOT, runId);
400
-
401
- console.log(`[extract] runId=${runId}`);
402
- console.log(`[extract] urls=${args.urls.length} viewport=${args.viewport.width}x${args.viewport.height}`);
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
- }
407
- console.log("");
408
-
409
- mkdirSync(outDir, { recursive: true });
410
-
411
- const limiter = new RateLimiter(args.rateLimitPerMinute);
412
- const captures: SiteCapture[] = [];
413
- const rawList: RawTokens[] = [];
414
- const layoutsByHost = new Map<string, SiteLayout>();
415
-
416
- let browser: Browser | null = null;
417
- try {
418
- browser = await chromium.launch();
419
-
420
- for (const url of args.urls) {
421
- const host = new URL(url).host;
422
-
423
- if (args.respectRobots) {
424
- const allowed = await isAllowedByRobots(url);
425
- if (!allowed) {
426
- console.log(` ⊘ ${url} skipped — robots.txt disallows`);
427
- captures.push({
428
- url,
429
- host,
430
- capturedAt: new Date().toISOString(),
431
- screenshotPath: "",
432
- status: "skipped",
433
- reason: "robots.txt",
434
- });
435
- continue;
436
- }
437
- }
438
-
439
- await limiter.wait(host);
440
- const result = await captureOne(browser, url, args.viewport, outDir, {
441
- skipAssetDownload: args.skipAssetDownload,
442
- });
443
- if (!result) continue;
444
- captures.push(result.capture);
445
- if (result.capture.status === "ok") {
446
- rawList.push(result.raw);
447
- if (result.layout) {
448
- layoutsByHost.set(host, result.layout);
449
- }
450
- const tag = result.layout ? "mirror" : "tokens-only";
451
- const sectionCount = result.layout?.sections.length ?? 0;
452
- console.log(
453
- ` ✓ ${url} → ${tag}${result.layout ? ` (${sectionCount} sections)` : ""}`,
454
- );
455
- } else {
456
- console.log(` ✗ ${url} ${result.capture.reason ?? ""}`);
457
- }
458
- }
459
- } finally {
460
- if (browser) await browser.close();
461
- }
462
-
463
- if (rawList.length === 0) {
464
- console.error("[extract] no successful captures — nothing to synthesize.");
465
- process.exit(1);
466
- }
467
-
468
- console.log("");
469
- console.log(`[extract] synthesizing design system from ${rawList.length} site(s)...`);
470
- const designSystem = synthesize(rawList, {
471
- runId,
472
- sources: rawList.map((r) => ({ url: r.url, capturedAt: r.capturedAt })),
473
- });
474
-
475
- const run: ExtractionRun = {
476
- runId,
477
- startedAt,
478
- finishedAt: new Date().toISOString(),
479
- outputDir: outDir,
480
- captures,
481
- designSystem,
482
- ...(args.saasIdea ? { saasIdea: args.saasIdea } : {}),
483
- };
484
-
485
- const written = emitAll(designSystem, run);
486
- const researchWritten = emitClonerResearch(run, layoutsByHost);
487
- writeFileSync(join(outDir, "run.json"), JSON.stringify(run, null, 2));
488
- console.log("");
489
- console.log("[extract] wrote:");
490
- for (const f of written) console.log(` → ${f}`);
491
- for (const f of researchWritten) console.log(` → ${f}`);
492
- console.log(` → ${join(outDir, "run.json")}`);
493
- const mirrorDirs = captures.filter((c) => c.mirrorDir).map((c) => c.mirrorDir!);
494
- const referenceDirs = captures.filter((c) => c.referenceDir).map((c) => c.referenceDir!);
495
- if (mirrorDirs.length > 0) {
496
- console.log("");
497
- console.log("[extract] layout mirrors:");
498
- for (const d of mirrorDirs) console.log(` → ${d}/page.tsx`);
499
- }
500
- if (referenceDirs.length > 0) {
501
- console.log("");
502
- console.log("[extract] AI reference (verbatim DOM + copy):");
503
- for (const d of referenceDirs) console.log(` → ${d}/FOR_AI_REFERENCE.md`);
504
- }
505
- console.log("");
506
- console.log(`[extract] done. Open ${join(outDir, "REPORT.md")} for the design-system summary.`);
507
- if (mirrorDirs.length > 0) {
508
- console.log(
509
- `[extract] each mirror folder ships a Next.js page.tsx + MIRROR_NOTES.md.`,
510
- );
511
- console.log(`[extract] fill the <TextSlot> / <MediaSlot> placeholders with your own content.`);
512
- }
513
- if (referenceDirs.length > 0) {
514
- console.log(
515
- `[extract] paste reference/<host>/visible-text.txt or page.html into your AI for exact structure + copy.`,
516
- );
517
- }
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/`);
521
- }
522
-
523
- function makeRunId(startedAt: string, name: string | undefined): string {
524
- const stamp = startedAt.replace(/[-:T]/g, "").slice(0, 14);
525
- return name ? `${stamp}-${name}` : stamp;
526
- }
527
-
528
- if (isMainModule(import.meta.url)) {
529
- main().catch((err) => {
530
- console.error(err);
531
- process.exit(1);
532
- });
533
- }
534
-
535
- /**
536
- * Cross-platform entry-point check. On Windows, `process.argv[1]` is a
537
- * backslash path while `import.meta.url` is a proper file URL, so the
538
- * naive `file://${argv[1]}` template literal never matches and the
539
- * script silently exits. `pathToFileURL` produces the encoded URL form
540
- * on every platform.
541
- */
542
- function isMainModule(metaUrl: string): boolean {
543
- const entry = process.argv[1];
544
- if (!entry) return false;
545
- return metaUrl === pathToFileURL(entry).href;
546
- }
547
-
548
- export { main };
@@ -1,5 +0,0 @@
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
- }