launchframe 0.1.4 → 0.1.6
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.
- package/README.md +61 -34
- package/package.json +64 -64
- package/packages/extract/dom-crawler.ts +521 -0
- package/packages/extract/emit.ts +2 -2
- package/packages/extract/extract.ts +66 -16
- package/packages/extract/mirror-emit.ts +522 -0
- package/packages/extract/types.ts +118 -0
|
@@ -0,0 +1,522 @@
|
|
|
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 never embeds the source's verbatim copy text or brand
|
|
13
|
+
* assets. Headings, body copy, buttons, images, and logos are rendered as
|
|
14
|
+
* slot placeholders that the operator fills in.
|
|
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
|
+
"/**",
|
|
62
|
+
` * Mirror page for ${layout.url}`,
|
|
63
|
+
` * Captured ${layout.capturedAt}`,
|
|
64
|
+
` * Viewport ${layout.viewport.width}×${layout.viewport.height}`,
|
|
65
|
+
" *",
|
|
66
|
+
" * Generated by launchframe — packages/extract/mirror-emit.ts",
|
|
67
|
+
" *",
|
|
68
|
+
" * Fill in <TextSlot> and <MediaSlot> placeholders with your own copy",
|
|
69
|
+
" * and imagery before shipping. The visual tokens (color, type, radius,",
|
|
70
|
+
' * container width) reflect the source page and are scoped to the',
|
|
71
|
+
' * `.mirror-root` wrapper below — no global CSS leakage.',
|
|
72
|
+
" */",
|
|
73
|
+
"",
|
|
74
|
+
'import {',
|
|
75
|
+
" FadeUp,",
|
|
76
|
+
" MediaSlot,",
|
|
77
|
+
" Stagger,",
|
|
78
|
+
" StaggerItem,",
|
|
79
|
+
" TextSlot,",
|
|
80
|
+
" cn,",
|
|
81
|
+
"} from \"@framework/blocks\";",
|
|
82
|
+
"",
|
|
83
|
+
`export const meta = ${JSON.stringify(
|
|
84
|
+
{
|
|
85
|
+
kind: "mirror",
|
|
86
|
+
source: layout.url,
|
|
87
|
+
capturedAt: layout.capturedAt,
|
|
88
|
+
sections: layout.sections.map((s) => ({
|
|
89
|
+
id: s.id,
|
|
90
|
+
role: s.role,
|
|
91
|
+
composition: s.composition,
|
|
92
|
+
density: s.density,
|
|
93
|
+
})),
|
|
94
|
+
},
|
|
95
|
+
null,
|
|
96
|
+
2,
|
|
97
|
+
)} as const;`,
|
|
98
|
+
"",
|
|
99
|
+
`export default function ${componentName}() {`,
|
|
100
|
+
" return (",
|
|
101
|
+
` <div className="mirror-root">`,
|
|
102
|
+
` <style>{${JSON.stringify(emitScopedTokens(layout.tokens))}}</style>`,
|
|
103
|
+
...sectionExprs.map((expr) => " " + expr.split("\n").join("\n ")),
|
|
104
|
+
" </div>",
|
|
105
|
+
" );",
|
|
106
|
+
"}",
|
|
107
|
+
"",
|
|
108
|
+
].join("\n");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function emitScopedTokens(tokens: SiteTokens): string {
|
|
112
|
+
const radius = `${(tokens.radiusPx / 16).toFixed(3)}rem`;
|
|
113
|
+
const containerPx = tokens.containerPx ?? 1120;
|
|
114
|
+
return [
|
|
115
|
+
`.mirror-root {`,
|
|
116
|
+
` --mirror-background: ${tokens.backgroundHex};`,
|
|
117
|
+
` --mirror-foreground: ${tokens.foregroundHex};`,
|
|
118
|
+
` --mirror-primary: ${tokens.primaryHex};`,
|
|
119
|
+
` --mirror-muted: ${tokens.mutedHex};`,
|
|
120
|
+
` --mirror-border: ${tokens.borderHex};`,
|
|
121
|
+
` --mirror-radius: ${radius};`,
|
|
122
|
+
` --mirror-container: ${containerPx}px;`,
|
|
123
|
+
` --mirror-font-body: ${escapeFontFamily(tokens.bodyFontFamily)};`,
|
|
124
|
+
` --mirror-font-heading: ${escapeFontFamily(tokens.headingFontFamily)};`,
|
|
125
|
+
` background: var(--mirror-background);`,
|
|
126
|
+
` color: var(--mirror-foreground);`,
|
|
127
|
+
` font-family: var(--mirror-font-body);`,
|
|
128
|
+
`}`,
|
|
129
|
+
`.mirror-root h1, .mirror-root h2, .mirror-root h3 {`,
|
|
130
|
+
` font-family: var(--mirror-font-heading);`,
|
|
131
|
+
`}`,
|
|
132
|
+
`.mirror-section { padding-block: 5rem; }`,
|
|
133
|
+
`@media (min-width: 768px) { .mirror-section { padding-block: 7rem; } }`,
|
|
134
|
+
`.mirror-container { max-width: var(--mirror-container); margin-inline: auto; padding-inline: 1.5rem; }`,
|
|
135
|
+
].join("\n");
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function escapeFontFamily(family: string): string {
|
|
139
|
+
const trimmed = family.replace(/^["']|["']$/g, "");
|
|
140
|
+
if (/^[a-zA-Z0-9_-]+$/.test(trimmed)) return `${trimmed}, system-ui, sans-serif`;
|
|
141
|
+
return `"${trimmed}", system-ui, sans-serif`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/* -------------------------------------------------------------------------- */
|
|
145
|
+
/* Per-section rendering */
|
|
146
|
+
/* -------------------------------------------------------------------------- */
|
|
147
|
+
|
|
148
|
+
function emitSection(s: SectionLayout, tokens: SiteTokens, index: number): string {
|
|
149
|
+
const wrapperBg = s.styles.backgroundHex ?? tokens.backgroundHex;
|
|
150
|
+
const padTop = s.styles.paddingTopPx ?? null;
|
|
151
|
+
const padBottom = s.styles.paddingBottomPx ?? null;
|
|
152
|
+
const styleAttr = emitStyleAttr({
|
|
153
|
+
backgroundColor: wrapperBg,
|
|
154
|
+
paddingTop: padTop !== null ? `${padTop}px` : undefined,
|
|
155
|
+
paddingBottom: padBottom !== null ? `${padBottom}px` : undefined,
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
const labelId = `${s.id}-heading`;
|
|
159
|
+
const inner = emitSectionInner(s, labelId);
|
|
160
|
+
|
|
161
|
+
return [
|
|
162
|
+
`<section`,
|
|
163
|
+
` aria-labelledby=${JSON.stringify(labelId)}`,
|
|
164
|
+
` data-mirror-section=${JSON.stringify(`${s.id}:${s.role}:${s.composition}:${s.density}`)}`,
|
|
165
|
+
` className=${JSON.stringify(sectionWrapperClass(s.role, index))}`,
|
|
166
|
+
styleAttr ? ` style={${styleAttr}}` : "",
|
|
167
|
+
`>`,
|
|
168
|
+
` <div className="mirror-container">`,
|
|
169
|
+
inner.split("\n").map((l) => " " + l).join("\n"),
|
|
170
|
+
` </div>`,
|
|
171
|
+
`</section>`,
|
|
172
|
+
]
|
|
173
|
+
.filter((s) => s.length > 0)
|
|
174
|
+
.join("\n");
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function emitStyleAttr(style: {
|
|
178
|
+
backgroundColor?: string | null;
|
|
179
|
+
paddingTop?: string | undefined;
|
|
180
|
+
paddingBottom?: string | undefined;
|
|
181
|
+
}): string {
|
|
182
|
+
const entries: string[] = [];
|
|
183
|
+
if (style.backgroundColor)
|
|
184
|
+
entries.push(`backgroundColor: ${JSON.stringify(style.backgroundColor)}`);
|
|
185
|
+
if (style.paddingTop) entries.push(`paddingTop: ${JSON.stringify(style.paddingTop)}`);
|
|
186
|
+
if (style.paddingBottom)
|
|
187
|
+
entries.push(`paddingBottom: ${JSON.stringify(style.paddingBottom)}`);
|
|
188
|
+
if (entries.length === 0) return "";
|
|
189
|
+
return `{ ${entries.join(", ")} }`;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function sectionWrapperClass(role: SectionRole, index: number): string {
|
|
193
|
+
const base = "mirror-section";
|
|
194
|
+
if (role === "nav") return `${base} border-b border-border py-4 md:py-4`;
|
|
195
|
+
if (role === "footer") return `${base} border-t border-border bg-muted/30`;
|
|
196
|
+
if (role === "hero" && index === 0) return `${base} border-b border-border`;
|
|
197
|
+
return `${base} border-b border-border`;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function emitSectionInner(s: SectionLayout, labelId: string): string {
|
|
201
|
+
switch (s.composition) {
|
|
202
|
+
case "split-2":
|
|
203
|
+
return emitSplitTwo(s, labelId);
|
|
204
|
+
case "grid-2":
|
|
205
|
+
case "grid-3":
|
|
206
|
+
case "grid-4":
|
|
207
|
+
return emitGrid(s, labelId);
|
|
208
|
+
case "logo-row":
|
|
209
|
+
return emitLogoRow(s);
|
|
210
|
+
case "list":
|
|
211
|
+
return emitList(s, labelId);
|
|
212
|
+
case "single-column":
|
|
213
|
+
case "unknown":
|
|
214
|
+
default:
|
|
215
|
+
return emitSingleColumn(s, labelId);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function gridCols(composition: Composition): number {
|
|
220
|
+
if (composition === "grid-2") return 2;
|
|
221
|
+
if (composition === "grid-3") return 3;
|
|
222
|
+
if (composition === "grid-4") return 4;
|
|
223
|
+
return 1;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/* ---------- composition templates ---------- */
|
|
227
|
+
|
|
228
|
+
function emitSingleColumn(s: SectionLayout, labelId: string): string {
|
|
229
|
+
const slots = slotMap(s.slots);
|
|
230
|
+
const head: string[] = [];
|
|
231
|
+
|
|
232
|
+
const eyebrow = slots.eyebrow ?? 0;
|
|
233
|
+
for (let i = 0; i < eyebrow; i++) head.push(textSlot("eyebrow"));
|
|
234
|
+
|
|
235
|
+
if ((slots["heading-1"] ?? 0) > 0) {
|
|
236
|
+
head.push(textSlot("heading-1", { id: labelId, count: slots["heading-1"]! }));
|
|
237
|
+
} else if ((slots["heading-2"] ?? 0) > 0) {
|
|
238
|
+
head.push(textSlot("heading-2", { id: labelId, count: 1 }));
|
|
239
|
+
}
|
|
240
|
+
const body = slots.body ?? 0;
|
|
241
|
+
for (let i = 0; i < Math.min(body, 3); i++) head.push(textSlot("body"));
|
|
242
|
+
|
|
243
|
+
const buttons: string[] = [];
|
|
244
|
+
if ((slots["button-primary"] ?? 0) > 0) buttons.push(textSlot("button-primary"));
|
|
245
|
+
if ((slots["button-secondary"] ?? 0) > 0) buttons.push(textSlot("button-secondary"));
|
|
246
|
+
|
|
247
|
+
const headBlock = head.length > 0 ? wrapFadeUp(head.join("\n")) : "";
|
|
248
|
+
const buttonsBlock =
|
|
249
|
+
buttons.length > 0
|
|
250
|
+
? `<div className="mt-8 flex flex-wrap items-center gap-3">\n${indent(buttons.join("\n"), 2)}\n</div>`
|
|
251
|
+
: "";
|
|
252
|
+
const mediaBlock = emitMediaSlots(slots);
|
|
253
|
+
|
|
254
|
+
return [headBlock, buttonsBlock, mediaBlock].filter(Boolean).join("\n");
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function emitSplitTwo(s: SectionLayout, labelId: string): string {
|
|
258
|
+
const slots = slotMap(s.slots);
|
|
259
|
+
const textChildren: string[] = [];
|
|
260
|
+
if ((slots.eyebrow ?? 0) > 0) textChildren.push(textSlot("eyebrow"));
|
|
261
|
+
if ((slots["heading-1"] ?? 0) > 0) {
|
|
262
|
+
textChildren.push(textSlot("heading-1", { id: labelId }));
|
|
263
|
+
} else if ((slots["heading-2"] ?? 0) > 0) {
|
|
264
|
+
textChildren.push(textSlot("heading-2", { id: labelId }));
|
|
265
|
+
}
|
|
266
|
+
const body = slots.body ?? 0;
|
|
267
|
+
for (let i = 0; i < Math.min(body, 2); i++) textChildren.push(textSlot("body"));
|
|
268
|
+
|
|
269
|
+
const bullets = slots.bullet ?? 0;
|
|
270
|
+
if (bullets > 0) {
|
|
271
|
+
const items: string[] = [];
|
|
272
|
+
for (let i = 0; i < Math.min(bullets, 5); i++) items.push(textSlot("bullet"));
|
|
273
|
+
textChildren.push(
|
|
274
|
+
[
|
|
275
|
+
'<ul className="mt-6 space-y-2 text-sm">',
|
|
276
|
+
...items.map((it) => indent(it, 1)),
|
|
277
|
+
"</ul>",
|
|
278
|
+
].join("\n"),
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const buttons: string[] = [];
|
|
283
|
+
if ((slots["button-primary"] ?? 0) > 0) buttons.push(textSlot("button-primary"));
|
|
284
|
+
if ((slots["button-secondary"] ?? 0) > 0) buttons.push(textSlot("button-secondary"));
|
|
285
|
+
if (buttons.length > 0) {
|
|
286
|
+
textChildren.push(
|
|
287
|
+
[
|
|
288
|
+
'<div className="mt-8 flex flex-wrap items-center gap-3">',
|
|
289
|
+
...buttons.map((b) => indent(b, 1)),
|
|
290
|
+
"</div>",
|
|
291
|
+
].join("\n"),
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const textCol = wrapFadeUp(textChildren.join("\n"));
|
|
296
|
+
const mediaCol = wrapFadeUp(emitMediaSlots(slots, { aspect: "video" }) || mediaSlot("image"));
|
|
297
|
+
|
|
298
|
+
return [
|
|
299
|
+
'<div className="grid items-center gap-12 md:grid-cols-2">',
|
|
300
|
+
` <div>${"\n" + indent(textCol, 2) + "\n "}</div>`,
|
|
301
|
+
` <div>${"\n" + indent(mediaCol, 2) + "\n "}</div>`,
|
|
302
|
+
"</div>",
|
|
303
|
+
].join("\n");
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function emitGrid(s: SectionLayout, labelId: string): string {
|
|
307
|
+
const slots = slotMap(s.slots);
|
|
308
|
+
const cols = gridCols(s.composition);
|
|
309
|
+
|
|
310
|
+
const heading =
|
|
311
|
+
(slots["heading-1"] ?? 0) > 0
|
|
312
|
+
? textSlot("heading-1", { id: labelId })
|
|
313
|
+
: (slots["heading-2"] ?? 0) > 0
|
|
314
|
+
? textSlot("heading-2", { id: labelId })
|
|
315
|
+
: "";
|
|
316
|
+
const introBody =
|
|
317
|
+
(slots.body ?? 0) > 0 ? `<div className="mt-4">${textSlot("body")}</div>` : "";
|
|
318
|
+
|
|
319
|
+
const cardHeading: SlotKind =
|
|
320
|
+
(slots["heading-2"] ?? 0) >= cols ? "heading-2" : "heading-3";
|
|
321
|
+
|
|
322
|
+
const card = [
|
|
323
|
+
'<li className="flex flex-col gap-3 rounded-lg border border-border bg-card p-6">',
|
|
324
|
+
` ${mediaSlot("icon", { aspect: "square", className: "size-10" })}`,
|
|
325
|
+
` ${textSlot(cardHeading)}`,
|
|
326
|
+
` ${textSlot("body")}`,
|
|
327
|
+
"</li>",
|
|
328
|
+
].join("\n");
|
|
329
|
+
|
|
330
|
+
const cards: string[] = [];
|
|
331
|
+
for (let i = 0; i < cols; i++) cards.push(card);
|
|
332
|
+
const grid = [
|
|
333
|
+
`<Stagger as="ul" className=${JSON.stringify(`mt-12 grid gap-6 md:grid-cols-${cols}`)}>`,
|
|
334
|
+
...cards.map((c) =>
|
|
335
|
+
[
|
|
336
|
+
' <StaggerItem as="li" className="flex flex-col gap-3 rounded-lg border border-border bg-card p-6">',
|
|
337
|
+
` ${mediaSlot("icon", { aspect: "square", className: "size-10" })}`,
|
|
338
|
+
` ${textSlot(cardHeading)}`,
|
|
339
|
+
` ${textSlot("body")}`,
|
|
340
|
+
" </StaggerItem>",
|
|
341
|
+
].join("\n"),
|
|
342
|
+
),
|
|
343
|
+
"</Stagger>",
|
|
344
|
+
];
|
|
345
|
+
|
|
346
|
+
const head = [
|
|
347
|
+
heading ? wrapFadeUp(heading) : "",
|
|
348
|
+
introBody,
|
|
349
|
+
]
|
|
350
|
+
.filter(Boolean)
|
|
351
|
+
.join("\n");
|
|
352
|
+
|
|
353
|
+
return [head, grid.join("\n")].filter(Boolean).join("\n");
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function emitLogoRow(s: SectionLayout): string {
|
|
357
|
+
const slots = slotMap(s.slots);
|
|
358
|
+
const count = Math.max(4, Math.min(slots["logo-mono"] ?? 6, 8));
|
|
359
|
+
const items: string[] = [];
|
|
360
|
+
for (let i = 0; i < count; i++) {
|
|
361
|
+
items.push(mediaSlot("logo-mono", { aspect: "auto", className: "h-7 w-24" }));
|
|
362
|
+
}
|
|
363
|
+
return [
|
|
364
|
+
'<div className="text-center text-xs font-semibold uppercase tracking-wider text-muted-foreground">',
|
|
365
|
+
" <TextSlot kind=\"eyebrow\" />",
|
|
366
|
+
"</div>",
|
|
367
|
+
'<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">',
|
|
368
|
+
...items.map((it) => ` ${it}`),
|
|
369
|
+
"</div>",
|
|
370
|
+
].join("\n");
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function emitList(s: SectionLayout, labelId: string): string {
|
|
374
|
+
const slots = slotMap(s.slots);
|
|
375
|
+
const heading =
|
|
376
|
+
(slots["heading-1"] ?? 0) > 0
|
|
377
|
+
? textSlot("heading-1", { id: labelId })
|
|
378
|
+
: (slots["heading-2"] ?? 0) > 0
|
|
379
|
+
? textSlot("heading-2", { id: labelId })
|
|
380
|
+
: "";
|
|
381
|
+
|
|
382
|
+
const items: string[] = [];
|
|
383
|
+
const count = Math.max(3, Math.min(slots.bullet ?? 5, 8));
|
|
384
|
+
for (let i = 0; i < count; i++) items.push(textSlot("bullet"));
|
|
385
|
+
|
|
386
|
+
return [
|
|
387
|
+
heading ? wrapFadeUp(heading) : "",
|
|
388
|
+
`<ul className="mt-8 space-y-3 text-sm">`,
|
|
389
|
+
...items.map((it) => ` ${it}`),
|
|
390
|
+
"</ul>",
|
|
391
|
+
]
|
|
392
|
+
.filter(Boolean)
|
|
393
|
+
.join("\n");
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/* ---------- slot helpers ---------- */
|
|
397
|
+
|
|
398
|
+
function slotMap(slots: SlotCount[]): Partial<Record<SlotKind, number>> {
|
|
399
|
+
const out: Partial<Record<SlotKind, number>> = {};
|
|
400
|
+
for (const s of slots) out[s.kind] = s.count;
|
|
401
|
+
return out;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function textSlot(
|
|
405
|
+
kind: "eyebrow" | "heading-1" | "heading-2" | "heading-3" | "body" | "bullet" | "button-primary" | "button-secondary" | "badge",
|
|
406
|
+
opts: { id?: string; count?: number } = {},
|
|
407
|
+
): string {
|
|
408
|
+
const idAttr = opts.id ? ` id=${JSON.stringify(opts.id)}` : "";
|
|
409
|
+
return `<TextSlot kind=${JSON.stringify(kind)}${idAttr} />`;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function mediaSlot(
|
|
413
|
+
kind: "image" | "logo-mono" | "icon" | "video" | "code",
|
|
414
|
+
opts: { aspect?: "video" | "square" | "4/3" | "21/9" | "auto"; className?: string } = {},
|
|
415
|
+
): string {
|
|
416
|
+
const attrs: string[] = [`kind=${JSON.stringify(kind)}`];
|
|
417
|
+
if (opts.aspect) attrs.push(`aspect=${JSON.stringify(opts.aspect)}`);
|
|
418
|
+
if (opts.className) attrs.push(`className=${JSON.stringify(opts.className)}`);
|
|
419
|
+
return `<MediaSlot ${attrs.join(" ")} />`;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function emitMediaSlots(
|
|
423
|
+
slots: Partial<Record<SlotKind, number>>,
|
|
424
|
+
opts: { aspect?: "video" | "square" | "4/3" | "21/9" | "auto" } = {},
|
|
425
|
+
): string {
|
|
426
|
+
const parts: string[] = [];
|
|
427
|
+
if ((slots.image ?? 0) > 0) parts.push(mediaSlot("image", { aspect: opts.aspect ?? "video" }));
|
|
428
|
+
if ((slots.code ?? 0) > 0) parts.push(mediaSlot("code", { aspect: "auto" }));
|
|
429
|
+
if ((slots.video ?? 0) > 0) parts.push(mediaSlot("video", { aspect: opts.aspect ?? "video" }));
|
|
430
|
+
if (parts.length === 0) return "";
|
|
431
|
+
if (parts.length === 1) return `<div className="mt-12">${parts[0]}</div>`;
|
|
432
|
+
return [
|
|
433
|
+
'<div className="mt-12 grid gap-6 md:grid-cols-2">',
|
|
434
|
+
...parts.map((p) => ` ${p}`),
|
|
435
|
+
"</div>",
|
|
436
|
+
].join("\n");
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function wrapFadeUp(children: string): string {
|
|
440
|
+
return [
|
|
441
|
+
"<FadeUp>",
|
|
442
|
+
indent(children, 1),
|
|
443
|
+
"</FadeUp>",
|
|
444
|
+
].join("\n");
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function indent(s: string, levels: number): string {
|
|
448
|
+
const pad = " ".repeat(levels);
|
|
449
|
+
return s
|
|
450
|
+
.split("\n")
|
|
451
|
+
.map((l) => (l.length > 0 ? pad + l : l))
|
|
452
|
+
.join("\n");
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function toPascalCase(input: string): string {
|
|
456
|
+
return input
|
|
457
|
+
.split(/[^a-zA-Z0-9]+/)
|
|
458
|
+
.filter(Boolean)
|
|
459
|
+
.map((w) => w[0]!.toUpperCase() + w.slice(1))
|
|
460
|
+
.join("") || "Mirror";
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/* -------------------------------------------------------------------------- */
|
|
464
|
+
/* MIRROR_NOTES.md */
|
|
465
|
+
/* -------------------------------------------------------------------------- */
|
|
466
|
+
|
|
467
|
+
function emitNotes(layout: SiteLayout): string {
|
|
468
|
+
const lines: string[] = [];
|
|
469
|
+
lines.push(`# Mirror notes — ${layout.host}`);
|
|
470
|
+
lines.push("");
|
|
471
|
+
lines.push(`Source URL: ${layout.url}`);
|
|
472
|
+
lines.push(`Captured: ${layout.capturedAt}`);
|
|
473
|
+
lines.push(`Viewport: ${layout.viewport.width}×${layout.viewport.height}`);
|
|
474
|
+
lines.push(`Rendered page height: ${layout.pageHeightPx}px`);
|
|
475
|
+
lines.push("");
|
|
476
|
+
lines.push("## What was extracted");
|
|
477
|
+
lines.push("");
|
|
478
|
+
lines.push(
|
|
479
|
+
"- Top-level section tree (geometry, role, composition, density)",
|
|
480
|
+
);
|
|
481
|
+
lines.push("- Slot inventory per section (counts of headings / body / buttons / images / icons / logos)");
|
|
482
|
+
lines.push("- Per-section background and padding");
|
|
483
|
+
lines.push("- Page-level tokens (fonts, primary/muted/border/foreground, radius, container width)");
|
|
484
|
+
lines.push("");
|
|
485
|
+
lines.push("## What was **not** extracted");
|
|
486
|
+
lines.push("");
|
|
487
|
+
lines.push("- Source headlines, body copy, or microcopy text");
|
|
488
|
+
lines.push("- Brand logos, illustrations, or product screenshots");
|
|
489
|
+
lines.push("- Source HTML, CSS, or class names");
|
|
490
|
+
lines.push("");
|
|
491
|
+
lines.push(
|
|
492
|
+
"The `page.tsx` reconstructs the section grammar from typed primitives. Headings, body copy, buttons, images, and logos appear as `<TextSlot>` / `<MediaSlot>` placeholders. Fill them with your own content before shipping.",
|
|
493
|
+
);
|
|
494
|
+
lines.push("");
|
|
495
|
+
lines.push("## Section breakdown");
|
|
496
|
+
lines.push("");
|
|
497
|
+
lines.push("| # | role | composition | density | slots |");
|
|
498
|
+
lines.push("| - | ---- | ----------- | ------- | ----- |");
|
|
499
|
+
for (const s of layout.sections) {
|
|
500
|
+
const slotSummary = s.slots
|
|
501
|
+
.map((sl) => `${sl.kind}×${sl.count}`)
|
|
502
|
+
.join(", ");
|
|
503
|
+
lines.push(
|
|
504
|
+
`| ${s.id} | ${s.role} | ${s.composition} | ${s.density} | ${slotSummary || "—"} |`,
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
lines.push("");
|
|
508
|
+
lines.push("## How to fill in slots");
|
|
509
|
+
lines.push("");
|
|
510
|
+
lines.push("```tsx");
|
|
511
|
+
lines.push('// Empty (renders dashed placeholder):');
|
|
512
|
+
lines.push('<TextSlot kind="heading-1" />');
|
|
513
|
+
lines.push("");
|
|
514
|
+
lines.push("// Filled:");
|
|
515
|
+
lines.push('<TextSlot kind="heading-1">The thing your product does, in plain language.</TextSlot>');
|
|
516
|
+
lines.push('<MediaSlot kind="image" aspect="video">');
|
|
517
|
+
lines.push(" <Image src=\"/hero.jpg\" alt=\"Hero photograph\" width={1200} height={675} />");
|
|
518
|
+
lines.push("</MediaSlot>");
|
|
519
|
+
lines.push("```");
|
|
520
|
+
lines.push("");
|
|
521
|
+
return lines.join("\n");
|
|
522
|
+
}
|
|
@@ -170,6 +170,120 @@ export interface DesignSystem {
|
|
|
170
170
|
notes: string[];
|
|
171
171
|
}
|
|
172
172
|
|
|
173
|
+
/* -------------------------------------------------------------------------- */
|
|
174
|
+
/* Layout mirror (per-site) */
|
|
175
|
+
/* -------------------------------------------------------------------------- */
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Structural roles a top-level section can play. Inferred heuristically from
|
|
179
|
+
* geometry, content kinds, and document position — not from the source's
|
|
180
|
+
* class names.
|
|
181
|
+
*/
|
|
182
|
+
export type SectionRole =
|
|
183
|
+
| "nav"
|
|
184
|
+
| "hero"
|
|
185
|
+
| "feature-grid"
|
|
186
|
+
| "feature-deep-dive"
|
|
187
|
+
| "proof-logos"
|
|
188
|
+
| "proof-quotes"
|
|
189
|
+
| "pricing"
|
|
190
|
+
| "conversion"
|
|
191
|
+
| "footer"
|
|
192
|
+
| "other";
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Coarse composition shape used by the emitter to pick a wrapper layout.
|
|
196
|
+
*/
|
|
197
|
+
export type Composition =
|
|
198
|
+
| "single-column"
|
|
199
|
+
| "split-2"
|
|
200
|
+
| "grid-2"
|
|
201
|
+
| "grid-3"
|
|
202
|
+
| "grid-4"
|
|
203
|
+
| "list"
|
|
204
|
+
| "logo-row"
|
|
205
|
+
| "unknown";
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* The kinds of content a section can hold. The emitter renders an
|
|
209
|
+
* appropriately-styled placeholder (`<TextSlot>` / `<MediaSlot>`) per slot
|
|
210
|
+
* so the user fills in their own copy and assets.
|
|
211
|
+
*/
|
|
212
|
+
export type SlotKind =
|
|
213
|
+
| "heading-1"
|
|
214
|
+
| "heading-2"
|
|
215
|
+
| "heading-3"
|
|
216
|
+
| "eyebrow"
|
|
217
|
+
| "body"
|
|
218
|
+
| "bullet"
|
|
219
|
+
| "button-primary"
|
|
220
|
+
| "button-secondary"
|
|
221
|
+
| "image"
|
|
222
|
+
| "logo-mono"
|
|
223
|
+
| "icon"
|
|
224
|
+
| "code"
|
|
225
|
+
| "badge"
|
|
226
|
+
| "input"
|
|
227
|
+
| "video";
|
|
228
|
+
|
|
229
|
+
export interface SlotCount {
|
|
230
|
+
kind: SlotKind;
|
|
231
|
+
count: number;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* A single top-level section in document order. Geometry is normalized to
|
|
236
|
+
* [0, 1] over the rendered page so the emitter can compare relative weight.
|
|
237
|
+
*/
|
|
238
|
+
export interface SectionLayout {
|
|
239
|
+
/** Stable id assigned in document order: s1, s2, ... */
|
|
240
|
+
id: string;
|
|
241
|
+
role: SectionRole;
|
|
242
|
+
composition: Composition;
|
|
243
|
+
density: "thin" | "balanced" | "dense";
|
|
244
|
+
/** Bounding box [x, y, w, h] normalized to [0, 1] over the rendered page. */
|
|
245
|
+
bbox: [number, number, number, number];
|
|
246
|
+
/** Aggregated content-kind counts inside the section. */
|
|
247
|
+
slots: SlotCount[];
|
|
248
|
+
/** Per-section style hints; the emitter applies these as inline overrides. */
|
|
249
|
+
styles: {
|
|
250
|
+
backgroundHex: string | null;
|
|
251
|
+
foregroundHex: string | null;
|
|
252
|
+
paddingTopPx: number | null;
|
|
253
|
+
paddingBottomPx: number | null;
|
|
254
|
+
};
|
|
255
|
+
/** Free-form notes the emitter surfaces in `MIRROR_NOTES.md`. */
|
|
256
|
+
notes: string[];
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Page-level computed-style tokens. These complement the synthesized
|
|
261
|
+
* `DesignSystem` so a mirror page can apply a site-specific theme without
|
|
262
|
+
* the system having to reseed the cross-corpus palette.
|
|
263
|
+
*/
|
|
264
|
+
export interface SiteTokens {
|
|
265
|
+
bodyFontFamily: string;
|
|
266
|
+
headingFontFamily: string;
|
|
267
|
+
backgroundHex: string;
|
|
268
|
+
foregroundHex: string;
|
|
269
|
+
primaryHex: string;
|
|
270
|
+
mutedHex: string;
|
|
271
|
+
borderHex: string;
|
|
272
|
+
radiusPx: number;
|
|
273
|
+
containerPx: number | null;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export interface SiteLayout {
|
|
277
|
+
url: string;
|
|
278
|
+
host: string;
|
|
279
|
+
capturedAt: string;
|
|
280
|
+
viewport: { width: number; height: number };
|
|
281
|
+
/** Full rendered page height in CSS pixels. */
|
|
282
|
+
pageHeightPx: number;
|
|
283
|
+
sections: SectionLayout[];
|
|
284
|
+
tokens: SiteTokens;
|
|
285
|
+
}
|
|
286
|
+
|
|
173
287
|
/* -------------------------------------------------------------------------- */
|
|
174
288
|
/* Run summary */
|
|
175
289
|
/* -------------------------------------------------------------------------- */
|
|
@@ -180,6 +294,10 @@ export interface SiteCapture {
|
|
|
180
294
|
capturedAt: string;
|
|
181
295
|
screenshotPath: string;
|
|
182
296
|
rawTokensPath: string;
|
|
297
|
+
/** Path to the per-site `SiteLayout` JSON, if the mirror crawl succeeded. */
|
|
298
|
+
layoutPath?: string;
|
|
299
|
+
/** Path to the per-site mirror page directory, if emission succeeded. */
|
|
300
|
+
mirrorDir?: string;
|
|
183
301
|
status: "ok" | "skipped" | "failed";
|
|
184
302
|
reason?: string;
|
|
185
303
|
}
|