launchframe 0.1.13 → 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 -175
  2. package/bin/launchframe.mjs +234 -30
  3. package/package.json +52 -65
  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 -547
  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,620 +0,0 @@
1
- /**
2
- * Mirror emitter.
3
- *
4
- * Consumes a typed `SiteLayout` (produced by `dom-crawler.ts`) and writes:
5
- * - `<dir>/page.tsx` — a Next.js page that reconstructs the source's
6
- * section tree, grid composition, and density
7
- * from typed primitives, with `<TextSlot>` /
8
- * `<MediaSlot>` placeholders for content.
9
- * - `<dir>/layout.json` — the captured `SiteLayout` model for review.
10
- * - `<dir>/MIRROR_NOTES.md` — what was extracted, how slots are filled.
11
- *
12
- * The emitter renders **slot placeholders** in `page.tsx` for final copy;
13
- * pair this with `reference/<host>/visible-text.txt` or `page.html` for
14
- * verbatim source strings when prompting an AI.
15
- */
16
-
17
- import { mkdirSync, writeFileSync } from "node:fs";
18
- import { join } from "node:path";
19
-
20
- import type {
21
- Composition,
22
- SectionLayout,
23
- SectionRole,
24
- SiteLayout,
25
- SlotCount,
26
- SlotKind,
27
- SiteTokens,
28
- } from "./types.js";
29
-
30
- export function emitMirror(layout: SiteLayout, outDir: string): string[] {
31
- mkdirSync(outDir, { recursive: true });
32
- const written: string[] = [];
33
-
34
- const pagePath = join(outDir, "page.tsx");
35
- writeFileSync(pagePath, emitPage(layout));
36
- written.push(pagePath);
37
-
38
- const layoutPath = join(outDir, "layout.json");
39
- writeFileSync(layoutPath, JSON.stringify(layout, null, 2) + "\n");
40
- written.push(layoutPath);
41
-
42
- const notesPath = join(outDir, "MIRROR_NOTES.md");
43
- writeFileSync(notesPath, emitNotes(layout));
44
- written.push(notesPath);
45
-
46
- return written;
47
- }
48
-
49
- /* -------------------------------------------------------------------------- */
50
- /* page.tsx */
51
- /* -------------------------------------------------------------------------- */
52
-
53
- function emitPage(layout: SiteLayout): string {
54
- const componentName = toPascalCase(layout.host.replace(/[^a-z0-9]/gi, "-")) + "Mirror";
55
-
56
- const sectionExprs = layout.sections.map((s, i) =>
57
- emitSection(s, layout.tokens, i),
58
- );
59
-
60
- return [
61
- '"use client";',
62
- "",
63
- "/**",
64
- ` * Mirror page for ${layout.url}`,
65
- ` * Captured ${layout.capturedAt}`,
66
- ` * Viewport ${layout.viewport.width}×${layout.viewport.height}`,
67
- " *",
68
- " * Generated by launchframe — packages/extract/mirror-emit.ts",
69
- " *",
70
- " * Reference for exact copy + DOM: ../../reference/<host>/",
71
- " * (visible-text.txt, page.html, media.json).",
72
- " *",
73
- " * Framer Motion: MirrorEnter / MirrorEnterFromEnd / MirrorStaggerRow.",
74
- " * Icons: @phosphor-icons/react via @framework/blocks.",
75
- " * Swap <MediaSlot> for next/image or <video controls playsInline>.",
76
- " */",
77
- "",
78
- 'import {',
79
- " CaretRight,",
80
- " Clock,",
81
- " FileText,",
82
- " ListBullets,",
83
- " MirrorEnter,",
84
- " MirrorEnterFromEnd,",
85
- " MirrorStaggerRow,",
86
- " MediaSlot,",
87
- " PlayCircle,",
88
- " Queue,",
89
- " Sparkle,",
90
- " Stagger,",
91
- " StaggerItem,",
92
- " TextSlot,",
93
- " VideoCamera,",
94
- "} from \"@framework/blocks\";",
95
- "",
96
- `export const meta = ${JSON.stringify(
97
- {
98
- kind: "mirror",
99
- source: layout.url,
100
- capturedAt: layout.capturedAt,
101
- referencePath: `../../reference/${layout.host}`,
102
- sections: layout.sections.map((s) => ({
103
- id: s.id,
104
- role: s.role,
105
- composition: s.composition,
106
- density: s.density,
107
- })),
108
- },
109
- null,
110
- 2,
111
- )} as const;`,
112
- "",
113
- `export default function ${componentName}() {`,
114
- " return (",
115
- ` <div className="mirror-root">`,
116
- ` <style>{${JSON.stringify(emitScopedTokens(layout.tokens))}}</style>`,
117
- ...sectionExprs.map((expr) => " " + expr.split("\n").join("\n ")),
118
- " </div>",
119
- " );",
120
- "}",
121
- "",
122
- ].join("\n");
123
- }
124
-
125
- function emitScopedTokens(tokens: SiteTokens): string {
126
- const radius = `${(tokens.radiusPx / 16).toFixed(3)}rem`;
127
- const containerPx = tokens.containerPx ?? 1120;
128
- return [
129
- `.mirror-root {`,
130
- ` --mirror-background: ${tokens.backgroundHex};`,
131
- ` --mirror-foreground: ${tokens.foregroundHex};`,
132
- ` --mirror-primary: ${tokens.primaryHex};`,
133
- ` --mirror-muted: ${tokens.mutedHex};`,
134
- ` --mirror-border: ${tokens.borderHex};`,
135
- ` --mirror-radius: ${radius};`,
136
- ` --mirror-container: ${containerPx}px;`,
137
- ` --mirror-font-body: ${escapeFontFamily(tokens.bodyFontFamily)};`,
138
- ` --mirror-font-heading: ${escapeFontFamily(tokens.headingFontFamily)};`,
139
- ` background: var(--mirror-background);`,
140
- ` color: var(--mirror-foreground);`,
141
- ` font-family: var(--mirror-font-body);`,
142
- `}`,
143
- `.mirror-root h1, .mirror-root h2, .mirror-root h3 {`,
144
- ` font-family: var(--mirror-font-heading);`,
145
- `}`,
146
- `.mirror-section { padding-block: 5rem; }`,
147
- `@media (min-width: 768px) { .mirror-section { padding-block: 7rem; } }`,
148
- `.mirror-container { max-width: var(--mirror-container); margin-inline: auto; padding-inline: 1.5rem; }`,
149
- ].join("\n");
150
- }
151
-
152
- function escapeFontFamily(family: string): string {
153
- const trimmed = family.replace(/^["']|["']$/g, "");
154
- if (/^[a-zA-Z0-9_-]+$/.test(trimmed)) return `${trimmed}, system-ui, sans-serif`;
155
- return `"${trimmed}", system-ui, sans-serif`;
156
- }
157
-
158
- /* -------------------------------------------------------------------------- */
159
- /* Per-section rendering */
160
- /* -------------------------------------------------------------------------- */
161
-
162
- function emitSection(s: SectionLayout, tokens: SiteTokens, index: number): string {
163
- const wrapperBg = s.styles.backgroundHex ?? tokens.backgroundHex;
164
- const padTop = s.styles.paddingTopPx ?? null;
165
- const padBottom = s.styles.paddingBottomPx ?? null;
166
- const styleAttr = emitStyleAttr({
167
- backgroundColor: wrapperBg,
168
- paddingTop: padTop !== null ? `${padTop}px` : undefined,
169
- paddingBottom: padBottom !== null ? `${padBottom}px` : undefined,
170
- });
171
-
172
- const labelId = `${s.id}-heading`;
173
- const inner = emitSectionInner(s, labelId);
174
-
175
- return [
176
- `<section`,
177
- ` aria-labelledby=${JSON.stringify(labelId)}`,
178
- ` data-mirror-section=${JSON.stringify(`${s.id}:${s.role}:${s.composition}:${s.density}`)}`,
179
- ` className=${JSON.stringify(sectionWrapperClass(s.role, index))}`,
180
- styleAttr ? ` style={${styleAttr}}` : "",
181
- `>`,
182
- ` <div className="mirror-container">`,
183
- inner.split("\n").map((l) => " " + l).join("\n"),
184
- ` </div>`,
185
- `</section>`,
186
- ]
187
- .filter((s) => s.length > 0)
188
- .join("\n");
189
- }
190
-
191
- function emitStyleAttr(style: {
192
- backgroundColor?: string | null;
193
- paddingTop?: string | undefined;
194
- paddingBottom?: string | undefined;
195
- }): string {
196
- const entries: string[] = [];
197
- if (style.backgroundColor)
198
- entries.push(`backgroundColor: ${JSON.stringify(style.backgroundColor)}`);
199
- if (style.paddingTop) entries.push(`paddingTop: ${JSON.stringify(style.paddingTop)}`);
200
- if (style.paddingBottom)
201
- entries.push(`paddingBottom: ${JSON.stringify(style.paddingBottom)}`);
202
- if (entries.length === 0) return "";
203
- return `{ ${entries.join(", ")} }`;
204
- }
205
-
206
- function sectionWrapperClass(role: SectionRole, index: number): string {
207
- const base = "mirror-section";
208
- if (role === "nav") return `${base} border-b border-border py-4 md:py-4`;
209
- if (role === "footer") return `${base} border-t border-border bg-muted/30`;
210
- if (role === "hero" && index === 0) return `${base} border-b border-border`;
211
- return `${base} border-b border-border`;
212
- }
213
-
214
- function emitSectionInner(s: SectionLayout, labelId: string): string {
215
- switch (s.composition) {
216
- case "split-2":
217
- return emitSplitTwo(s, labelId);
218
- case "grid-2":
219
- case "grid-3":
220
- case "grid-4":
221
- return emitGrid(s, labelId);
222
- case "logo-row":
223
- return emitLogoRow(s);
224
- case "list":
225
- return emitList(s, labelId);
226
- case "single-column":
227
- case "unknown":
228
- default:
229
- return emitSingleColumn(s, labelId);
230
- }
231
- }
232
-
233
- function gridCols(composition: Composition): number {
234
- if (composition === "grid-2") return 2;
235
- if (composition === "grid-3") return 3;
236
- if (composition === "grid-4") return 4;
237
- return 1;
238
- }
239
-
240
- /* ---------- composition templates ---------- */
241
-
242
- function emitSingleColumn(s: SectionLayout, labelId: string): string {
243
- const slots = slotMap(s.slots);
244
- const head: string[] = [];
245
-
246
- const eyebrow = slots.eyebrow ?? 0;
247
- for (let i = 0; i < eyebrow; i++) head.push(textSlot("eyebrow"));
248
-
249
- if ((slots["heading-1"] ?? 0) > 0) {
250
- head.push(textSlot("heading-1", { id: labelId, count: slots["heading-1"]! }));
251
- } else if ((slots["heading-2"] ?? 0) > 0) {
252
- head.push(textSlot("heading-2", { id: labelId, count: 1 }));
253
- }
254
- const body = slots.body ?? 0;
255
- for (let i = 0; i < Math.min(body, 3); i++) head.push(textSlot("body"));
256
-
257
- const buttons: string[] = [];
258
- if ((slots["button-primary"] ?? 0) > 0) buttons.push(textSlot("button-primary"));
259
- if ((slots["button-secondary"] ?? 0) > 0) buttons.push(textSlot("button-secondary"));
260
-
261
- const headBlock = head.length > 0 ? wrapMirrorEnter(head.join("\n")) : "";
262
- const buttonsBlock =
263
- buttons.length > 0
264
- ? `<div className="mt-8 flex flex-wrap items-center gap-3">\n${indent(buttons.join("\n"), 2)}\n</div>`
265
- : "";
266
- const mediaBlock = emitMediaSlots(slots);
267
-
268
- return [headBlock, buttonsBlock, mediaBlock].filter(Boolean).join("\n");
269
- }
270
-
271
- function emitSplitTwo(s: SectionLayout, labelId: string): string {
272
- const slots = slotMap(s.slots);
273
- const textChildren: string[] = [];
274
- if ((slots.eyebrow ?? 0) > 0) textChildren.push(textSlot("eyebrow"));
275
- if ((slots["heading-1"] ?? 0) > 0) {
276
- textChildren.push(textSlot("heading-1", { id: labelId }));
277
- } else if ((slots["heading-2"] ?? 0) > 0) {
278
- textChildren.push(textSlot("heading-2", { id: labelId }));
279
- }
280
- const body = slots.body ?? 0;
281
- for (let i = 0; i < Math.min(body, 2); i++) textChildren.push(textSlot("body"));
282
-
283
- const bullets = slots.bullet ?? 0;
284
- if (bullets > 0) {
285
- const items: string[] = [];
286
- for (let i = 0; i < Math.min(bullets, 5); i++) items.push(textSlot("bullet"));
287
- textChildren.push(
288
- [
289
- '<ul className="mt-6 space-y-2 text-sm">',
290
- ...items.map((it) => indent(it, 1)),
291
- "</ul>",
292
- ].join("\n"),
293
- );
294
- }
295
-
296
- const buttons: string[] = [];
297
- if ((slots["button-primary"] ?? 0) > 0) {
298
- buttons.push(
299
- '<span className="inline-flex items-center gap-2 has-[button]:gap-0"><TextSlot kind="button-primary" /><CaretRight className="size-4 opacity-90" weight="bold" aria-hidden /></span>',
300
- );
301
- }
302
- if ((slots["button-secondary"] ?? 0) > 0) buttons.push(textSlot("button-secondary"));
303
- if (buttons.length > 0) {
304
- textChildren.push(
305
- [
306
- '<div className="mt-8 flex flex-wrap items-center gap-3">',
307
- ...buttons.map((b) => indent(b, 1)),
308
- "</div>",
309
- ].join("\n"),
310
- );
311
- }
312
-
313
- const textCol = wrapMirrorEnter(textChildren.join("\n"));
314
- const mediaInner = emitHeroMediaColumn(slots);
315
- const mediaCol = [`<MirrorEnterFromEnd>`, indent(mediaInner, 1), `</MirrorEnterFromEnd>`].join(
316
- "\n",
317
- );
318
-
319
- return [
320
- '<div className="grid items-center gap-12 md:grid-cols-2">',
321
- ` <div>${"\n" + indent(textCol, 2) + "\n "}</div>`,
322
- ` <div>${"\n" + indent(mediaCol, 2) + "\n "}</div>`,
323
- "</div>",
324
- ].join("\n");
325
- }
326
-
327
- /** Rich right column: product-style card + Phosphor + image/video slots. */
328
- function emitHeroMediaColumn(slots: Partial<Record<SlotKind, number>>): string {
329
- const hasVideo = (slots.video ?? 0) > 0;
330
- const queueCard = [
331
- '<div className="overflow-hidden rounded-xl border border-border bg-card shadow-lg ring-1 ring-foreground/[0.06]">',
332
- ' <header className="flex items-center justify-between gap-3 border-b border-border px-4 py-3">',
333
- ' <span className="flex items-center gap-2 min-w-0">',
334
- ' <Queue className="size-5 shrink-0 text-[var(--mirror-primary)]" weight="duotone" aria-hidden />',
335
- ' <TextSlot kind="eyebrow" />',
336
- " </span>",
337
- ' <TextSlot kind="badge" />',
338
- " </header>",
339
- ' <ul className="divide-y divide-border">',
340
- " <MirrorStaggerRow index={0} className=\"flex gap-3 px-4 py-3\">",
341
- ' <FileText className="size-5 shrink-0 text-[var(--mirror-primary)] mt-0.5" weight="duotone" aria-hidden />',
342
- ' <div className="min-w-0 space-y-1">',
343
- ' <TextSlot kind="heading-3" />',
344
- ' <TextSlot kind="body" />',
345
- " </div>",
346
- " </MirrorStaggerRow>",
347
- " <MirrorStaggerRow index={1} className=\"flex gap-3 px-4 py-3\">",
348
- ' <Clock className="size-5 shrink-0 text-[var(--mirror-primary)] mt-0.5" weight="duotone" aria-hidden />',
349
- ' <div className="min-w-0 space-y-1">',
350
- ' <TextSlot kind="heading-3" />',
351
- ' <TextSlot kind="body" />',
352
- " </div>",
353
- " </MirrorStaggerRow>",
354
- " <MirrorStaggerRow index={2} className=\"flex gap-3 px-4 py-3\">",
355
- ' <ListBullets className="size-5 shrink-0 text-[var(--mirror-primary)] mt-0.5" weight="duotone" aria-hidden />',
356
- ' <div className="min-w-0 space-y-1">',
357
- ' <TextSlot kind="heading-3" />',
358
- ' <TextSlot kind="body" />',
359
- " </div>",
360
- " </MirrorStaggerRow>",
361
- " </ul>",
362
- "</div>",
363
- ].join("\n");
364
-
365
- const imageBlock = [
366
- '<div className="mt-6 space-y-3">',
367
- ' <MediaSlot kind="image" aspect="video" />',
368
- " {/* Drop in when you have assets: import Image from 'next/image' */}",
369
- ' {/* <Image src="/hero.jpg" alt="" fill className="object-cover rounded-xl" sizes="(max-width:768px) 100vw, 50vw" /> */}',
370
- "</div>",
371
- ].join("\n");
372
-
373
- const videoBlock = hasVideo
374
- ? [
375
- '<div className="mt-6">',
376
- ' <div className="relative overflow-hidden rounded-xl border border-border bg-muted/30 aspect-video">',
377
- ' <VideoCamera className="absolute left-3 top-3 size-6 text-muted-foreground" weight="duotone" aria-hidden />',
378
- ' <video className="h-full w-full object-cover" controls playsInline preload="metadata" poster="/poster-frame.jpg">',
379
- ' <source src="/product-demo.mp4" type="video/mp4" />',
380
- " </video>",
381
- " </div>",
382
- ' <p className="mt-2 text-xs text-muted-foreground">No autoplay — swap poster + src from reference/media.json.</p>',
383
- "</div>",
384
- ].join("\n")
385
- : [
386
- '<div className="mt-6 flex items-center gap-2 text-xs text-muted-foreground">',
387
- ' <PlayCircle className="size-4 shrink-0" weight="regular" aria-hidden />',
388
- ' <span>Optional: add a <code className="font-mono">video</code> block; see media.json from the crawl.</span>',
389
- "</div>",
390
- ].join("\n");
391
-
392
- return [queueCard, imageBlock, videoBlock].join("\n");
393
- }
394
-
395
- function emitGrid(s: SectionLayout, labelId: string): string {
396
- const slots = slotMap(s.slots);
397
- const cols = gridCols(s.composition);
398
-
399
- const heading =
400
- (slots["heading-1"] ?? 0) > 0
401
- ? textSlot("heading-1", { id: labelId })
402
- : (slots["heading-2"] ?? 0) > 0
403
- ? textSlot("heading-2", { id: labelId })
404
- : "";
405
- const introBody =
406
- (slots.body ?? 0) > 0 ? `<div className="mt-4">${textSlot("body")}</div>` : "";
407
-
408
- const cardHeading: SlotKind =
409
- (slots["heading-2"] ?? 0) >= cols ? "heading-2" : "heading-3";
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
-
414
- const grid = [
415
- `<Stagger as="ul" className=${JSON.stringify(`mt-12 grid gap-6 md:grid-cols-${cols}`)}>`,
416
- ...cards.map(() =>
417
- [
418
- ' <StaggerItem as="li" className="flex flex-col gap-3 rounded-lg border border-border bg-card p-6">',
419
- ' <Sparkle className="size-10 text-[var(--mirror-primary)]" weight="duotone" aria-hidden />',
420
- ` ${textSlot(cardHeading)}`,
421
- ` ${textSlot("body")}`,
422
- " </StaggerItem>",
423
- ].join("\n"),
424
- ),
425
- "</Stagger>",
426
- ];
427
-
428
- const head = [
429
- heading ? wrapMirrorEnter(heading) : "",
430
- introBody,
431
- ]
432
- .filter(Boolean)
433
- .join("\n");
434
-
435
- return [head, grid.join("\n")].filter(Boolean).join("\n");
436
- }
437
-
438
- function emitLogoRow(s: SectionLayout): string {
439
- const slots = slotMap(s.slots);
440
- const count = Math.max(4, Math.min(slots["logo-mono"] ?? 6, 8));
441
- const items: string[] = [];
442
- for (let i = 0; i < count; i++) {
443
- items.push(mediaSlot("logo-mono", { aspect: "auto", className: "h-7 w-24" }));
444
- }
445
- return [
446
- '<div className="text-center text-xs font-semibold uppercase tracking-wider text-muted-foreground">',
447
- " <TextSlot kind=\"eyebrow\" />",
448
- "</div>",
449
- '<div className="mt-8 grid grid-cols-2 items-center justify-items-center gap-x-10 gap-y-6 sm:grid-cols-3 md:grid-cols-6">',
450
- ...items.map((it) => ` ${it}`),
451
- "</div>",
452
- ].join("\n");
453
- }
454
-
455
- function emitList(s: SectionLayout, labelId: string): string {
456
- const slots = slotMap(s.slots);
457
- const heading =
458
- (slots["heading-1"] ?? 0) > 0
459
- ? textSlot("heading-1", { id: labelId })
460
- : (slots["heading-2"] ?? 0) > 0
461
- ? textSlot("heading-2", { id: labelId })
462
- : "";
463
-
464
- const items: string[] = [];
465
- const count = Math.max(3, Math.min(slots.bullet ?? 5, 8));
466
- for (let i = 0; i < count; i++) items.push(textSlot("bullet"));
467
-
468
- return [
469
- heading ? wrapMirrorEnter(heading) : "",
470
- `<ul className="mt-8 space-y-3 text-sm">`,
471
- ...items.map((it) => ` ${it}`),
472
- "</ul>",
473
- ]
474
- .filter(Boolean)
475
- .join("\n");
476
- }
477
-
478
- /* ---------- slot helpers ---------- */
479
-
480
- function slotMap(slots: SlotCount[]): Partial<Record<SlotKind, number>> {
481
- const out: Partial<Record<SlotKind, number>> = {};
482
- for (const s of slots) out[s.kind] = s.count;
483
- return out;
484
- }
485
-
486
- function textSlot(
487
- kind: "eyebrow" | "heading-1" | "heading-2" | "heading-3" | "body" | "bullet" | "button-primary" | "button-secondary" | "badge",
488
- opts: { id?: string; count?: number } = {},
489
- ): string {
490
- const idAttr = opts.id ? ` id=${JSON.stringify(opts.id)}` : "";
491
- return `<TextSlot kind=${JSON.stringify(kind)}${idAttr} />`;
492
- }
493
-
494
- function mediaSlot(
495
- kind: "image" | "logo-mono" | "icon" | "video" | "code",
496
- opts: { aspect?: "video" | "square" | "4/3" | "21/9" | "auto"; className?: string } = {},
497
- ): string {
498
- const attrs: string[] = [`kind=${JSON.stringify(kind)}`];
499
- if (opts.aspect) attrs.push(`aspect=${JSON.stringify(opts.aspect)}`);
500
- if (opts.className) attrs.push(`className=${JSON.stringify(opts.className)}`);
501
- return `<MediaSlot ${attrs.join(" ")} />`;
502
- }
503
-
504
- function emitMediaSlots(
505
- slots: Partial<Record<SlotKind, number>>,
506
- opts: { aspect?: "video" | "square" | "4/3" | "21/9" | "auto" } = {},
507
- ): string {
508
- const parts: string[] = [];
509
- if ((slots.image ?? 0) > 0) parts.push(mediaSlot("image", { aspect: opts.aspect ?? "video" }));
510
- if ((slots.code ?? 0) > 0) parts.push(mediaSlot("code", { aspect: "auto" }));
511
- if ((slots.video ?? 0) > 0) {
512
- parts.push(
513
- [
514
- '<div className="overflow-hidden rounded-xl border border-border aspect-video bg-muted/20">',
515
- ' <video className="h-full w-full object-cover" controls playsInline preload="metadata" poster="/poster.jpg">',
516
- ' <source src="/clip.mp4" type="video/mp4" />',
517
- " </video>",
518
- "</div>",
519
- ].join("\n"),
520
- );
521
- }
522
- if (parts.length === 0) return "";
523
- if (parts.length === 1) return `<div className="mt-12">${parts[0]}</div>`;
524
- return [
525
- '<div className="mt-12 grid gap-6 md:grid-cols-2">',
526
- ...parts.map((p) => ` ${p}`),
527
- "</div>",
528
- ].join("\n");
529
- }
530
-
531
- function wrapMirrorEnter(children: string): string {
532
- return [
533
- "<MirrorEnter>",
534
- indent(children, 1),
535
- "</MirrorEnter>",
536
- ].join("\n");
537
- }
538
-
539
- function indent(s: string, levels: number): string {
540
- const pad = " ".repeat(levels);
541
- return s
542
- .split("\n")
543
- .map((l) => (l.length > 0 ? pad + l : l))
544
- .join("\n");
545
- }
546
-
547
- function toPascalCase(input: string): string {
548
- return input
549
- .split(/[^a-zA-Z0-9]+/)
550
- .filter(Boolean)
551
- .map((w) => w[0]!.toUpperCase() + w.slice(1))
552
- .join("") || "Mirror";
553
- }
554
-
555
- /* -------------------------------------------------------------------------- */
556
- /* MIRROR_NOTES.md */
557
- /* -------------------------------------------------------------------------- */
558
-
559
- function emitNotes(layout: SiteLayout): string {
560
- const lines: string[] = [];
561
- lines.push(`# Mirror notes — ${layout.host}`);
562
- lines.push("");
563
- lines.push(`Source URL: ${layout.url}`);
564
- lines.push(`Captured: ${layout.capturedAt}`);
565
- lines.push(`Viewport: ${layout.viewport.width}×${layout.viewport.height}`);
566
- lines.push(`Rendered page height: ${layout.pageHeightPx}px`);
567
- lines.push("");
568
- lines.push("## What was extracted");
569
- lines.push("");
570
- lines.push(
571
- "- Top-level section tree (geometry, role, composition, density)",
572
- );
573
- lines.push("- Slot inventory per section (counts of headings / body / buttons / images / icons / logos)");
574
- lines.push("- Per-section background and padding");
575
- lines.push("- Page-level tokens (fonts, primary/muted/border/foreground, radius, container width)");
576
- lines.push("- **Polished UI shell** in `page.tsx`: Framer Motion (`MirrorEnter`, `MirrorEnterFromEnd`, `MirrorStaggerRow`), Phosphor icons, image + video placeholders");
577
- lines.push("");
578
- lines.push("## Verbatim reference (same crawl)");
579
- lines.push("");
580
- lines.push(`Open **../reference/${layout.host}/** alongside this folder:`);
581
- lines.push("");
582
- lines.push("- `page.html` — full serialized DOM after JS (exact structure for an AI)");
583
- lines.push("- `visible-text.txt` / `visible-text.json` — visible headings, paragraphs, buttons");
584
- lines.push("- `media.json` — image and video URLs from the page");
585
- lines.push("- `FOR_AI_REFERENCE.md` — how to use the bundle");
586
- lines.push("");
587
- lines.push("Paste `visible-text.txt` or excerpts into your AI when filling `<TextSlot>` nodes.");
588
- lines.push("");
589
- lines.push("## What stays as slots in page.tsx");
590
- lines.push("");
591
- lines.push("`<TextSlot>` / `<MediaSlot>` keep the React tree clean until you substitute real strings and assets. Copy text from `visible-text.txt`; swap `poster` / `src` on `<video>` and `next/image` from `media.json`.");
592
- lines.push("");
593
- lines.push("## Section breakdown");
594
- lines.push("");
595
- lines.push("| # | role | composition | density | slots |");
596
- lines.push("| - | ---- | ----------- | ------- | ----- |");
597
- for (const s of layout.sections) {
598
- const slotSummary = s.slots
599
- .map((sl) => `${sl.kind}×${sl.count}`)
600
- .join(", ");
601
- lines.push(
602
- `| ${s.id} | ${s.role} | ${s.composition} | ${s.density} | ${slotSummary || "—"} |`,
603
- );
604
- }
605
- lines.push("");
606
- lines.push("## How to fill in slots");
607
- lines.push("");
608
- lines.push("```tsx");
609
- lines.push('// Empty (renders dashed placeholder):');
610
- lines.push('<TextSlot kind="heading-1" />');
611
- lines.push("");
612
- lines.push("// Filled:");
613
- lines.push('<TextSlot kind="heading-1">The thing your product does, in plain language.</TextSlot>');
614
- lines.push('<MediaSlot kind="image" aspect="video">');
615
- lines.push(" <Image src=\"/hero.jpg\" alt=\"Hero photograph\" width={1200} height={675} />");
616
- lines.push("</MediaSlot>");
617
- lines.push("```");
618
- lines.push("");
619
- return lines.join("\n");
620
- }
@@ -1,13 +0,0 @@
1
- {
2
- "name": "@framework/extract",
3
- "version": "0.1.0",
4
- "private": true,
5
- "type": "module",
6
- "main": "./extract.ts",
7
- "scripts": {
8
- "extract": "tsx extract.ts"
9
- },
10
- "dependencies": {
11
- "playwright": "^1.48.0"
12
- }
13
- }