launchframe 0.1.5 → 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 +1 -1
- 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
package/packages/extract/emit.ts
CHANGED
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
* globals.css — shadcn-compatible CSS variables (light + dark)
|
|
10
10
|
* theme-preview.tsx — a self-contained React component that renders
|
|
11
11
|
* every token so you can eyeball the system
|
|
12
|
-
* REPORT.md — what was extracted, from where,
|
|
13
|
-
*
|
|
12
|
+
* REPORT.md — what was extracted, from where, and how the
|
|
13
|
+
* output is meant to be used
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import { mkdirSync, writeFileSync } from "node:fs";
|
|
@@ -4,16 +4,23 @@
|
|
|
4
4
|
* npm run extract -- https://site-a.com https://site-b.com https://site-c.com
|
|
5
5
|
*
|
|
6
6
|
* For each URL: open in Chromium, screenshot, harvest computed design
|
|
7
|
-
* tokens via `browser-extract.ts
|
|
8
|
-
*
|
|
7
|
+
* tokens via `browser-extract.ts`, and crawl the rendered DOM into a
|
|
8
|
+
* typed `SiteLayout` model via `dom-crawler.ts`. After all sites:
|
|
9
|
+
* - Synthesize a drop-in shadcn-compatible design system from the
|
|
10
|
+
* aggregated tokens.
|
|
11
|
+
* - Emit a per-site **layout mirror**: a Next.js page that reconstructs
|
|
12
|
+
* the source's section structure from typed primitives, with
|
|
13
|
+
* `<TextSlot>` / `<MediaSlot>` placeholders for the user's copy and
|
|
14
|
+
* brand assets.
|
|
9
15
|
*
|
|
10
16
|
* Output goes to `output/<runId>/`.
|
|
11
17
|
*
|
|
12
|
-
*
|
|
13
|
-
* - Honor robots.txt
|
|
14
|
-
* - Per-domain rate limit defaults to 15 req/min.
|
|
15
|
-
* -
|
|
16
|
-
*
|
|
18
|
+
* Operational defaults (configurable via flags):
|
|
19
|
+
* - Honor robots.txt unless `--no-robots` is passed.
|
|
20
|
+
* - Per-domain rate limit defaults to 15 req/min (`--rate <n>`).
|
|
21
|
+
* - The crawler extracts a structured representation (section tree,
|
|
22
|
+
* computed style tokens, content kinds); it does not store raw HTML,
|
|
23
|
+
* copy text, or third-party assets in the output.
|
|
17
24
|
*/
|
|
18
25
|
|
|
19
26
|
import { mkdirSync, writeFileSync } from "node:fs";
|
|
@@ -23,9 +30,11 @@ import { fileURLToPath, pathToFileURL } from "node:url";
|
|
|
23
30
|
import { chromium, type Browser } from "playwright";
|
|
24
31
|
|
|
25
32
|
import { harvestTokens } from "./browser-extract.js";
|
|
33
|
+
import { crawlLayout } from "./dom-crawler.js";
|
|
26
34
|
import { emitAll } from "./emit.js";
|
|
35
|
+
import { emitMirror } from "./mirror-emit.js";
|
|
27
36
|
import { synthesize } from "./synthesize.js";
|
|
28
|
-
import type { ExtractionRun, RawTokens, SiteCapture } from "./types.js";
|
|
37
|
+
import type { ExtractionRun, RawTokens, SiteCapture, SiteLayout } from "./types.js";
|
|
29
38
|
|
|
30
39
|
const __filename = fileURLToPath(import.meta.url);
|
|
31
40
|
const __dirname = dirname(__filename);
|
|
@@ -90,9 +99,18 @@ function printHelp(): void {
|
|
|
90
99
|
"Writes to ./output/<runId>/ in your current working directory unless",
|
|
91
100
|
"you pass --out.",
|
|
92
101
|
"",
|
|
93
|
-
"
|
|
94
|
-
"
|
|
95
|
-
"
|
|
102
|
+
"For each URL the CLI:",
|
|
103
|
+
" 1. Renders the page at a desktop viewport in headless Chromium.",
|
|
104
|
+
" 2. Harvests computed design tokens (colors, type, spacing, radius,",
|
|
105
|
+
" shadow).",
|
|
106
|
+
" 3. Crawls the rendered DOM into a typed SiteLayout (section tree,",
|
|
107
|
+
" composition, slot counts, per-section style tokens).",
|
|
108
|
+
" 4. Emits a layout-mirror Next.js page at",
|
|
109
|
+
" output/<runId>/mirror/<host>/page.tsx with <TextSlot> /",
|
|
110
|
+
" <MediaSlot> placeholders for your own copy and imagery.",
|
|
111
|
+
"",
|
|
112
|
+
"After every URL, a drop-in shadcn-compatible design system is",
|
|
113
|
+
"synthesized from the aggregated tokens and written to output/<runId>/.",
|
|
96
114
|
"",
|
|
97
115
|
"Options:",
|
|
98
116
|
" --out <dir> Output directory (default: output/<runId>)",
|
|
@@ -178,11 +196,13 @@ async function captureOne(
|
|
|
178
196
|
url: string,
|
|
179
197
|
viewport: { width: number; height: number },
|
|
180
198
|
outDir: string,
|
|
181
|
-
): Promise<{ raw: RawTokens; capture: SiteCapture } | null> {
|
|
199
|
+
): Promise<{ raw: RawTokens; layout: SiteLayout | null; capture: SiteCapture } | null> {
|
|
182
200
|
const host = new URL(url).host;
|
|
183
201
|
const stamp = `${host}.png`;
|
|
184
202
|
const screenshotPath = join(outDir, "screenshots", stamp);
|
|
185
203
|
const rawPath = join(outDir, "raw", `${host}.tokens.json`);
|
|
204
|
+
const layoutPath = join(outDir, "raw", `${host}.layout.json`);
|
|
205
|
+
const mirrorDir = join(outDir, "mirror", host);
|
|
186
206
|
|
|
187
207
|
const ctx = await browser.newContext({
|
|
188
208
|
userAgent: USER_AGENT,
|
|
@@ -215,18 +235,32 @@ async function captureOne(
|
|
|
215
235
|
mkdirSync(dirname(rawPath), { recursive: true });
|
|
216
236
|
writeFileSync(rawPath, JSON.stringify(raw, null, 2));
|
|
217
237
|
|
|
238
|
+
let layout: SiteLayout | null = null;
|
|
239
|
+
let mirrorWritten: string[] = [];
|
|
240
|
+
try {
|
|
241
|
+
layout = await crawlLayout(page, url, viewport);
|
|
242
|
+
mkdirSync(dirname(layoutPath), { recursive: true });
|
|
243
|
+
writeFileSync(layoutPath, JSON.stringify(layout, null, 2));
|
|
244
|
+
mirrorWritten = emitMirror(layout, mirrorDir);
|
|
245
|
+
} catch (err) {
|
|
246
|
+
console.warn(` ! layout crawl failed for ${url}: ${(err as Error).message}`);
|
|
247
|
+
}
|
|
248
|
+
|
|
218
249
|
const capture: SiteCapture = {
|
|
219
250
|
url,
|
|
220
251
|
host,
|
|
221
252
|
capturedAt: raw.capturedAt,
|
|
222
253
|
screenshotPath,
|
|
223
254
|
rawTokensPath: rawPath,
|
|
255
|
+
...(layout ? { layoutPath } : {}),
|
|
256
|
+
...(mirrorWritten.length > 0 ? { mirrorDir } : {}),
|
|
224
257
|
status: "ok",
|
|
225
258
|
};
|
|
226
|
-
return { raw, capture };
|
|
259
|
+
return { raw, layout, capture };
|
|
227
260
|
} catch (err) {
|
|
228
261
|
return {
|
|
229
262
|
raw: emptyRaw(url, viewport),
|
|
263
|
+
layout: null,
|
|
230
264
|
capture: {
|
|
231
265
|
url,
|
|
232
266
|
host,
|
|
@@ -303,7 +337,11 @@ async function main(): Promise<void> {
|
|
|
303
337
|
captures.push(result.capture);
|
|
304
338
|
if (result.capture.status === "ok") {
|
|
305
339
|
rawList.push(result.raw);
|
|
306
|
-
|
|
340
|
+
const tag = result.layout ? "mirror" : "tokens-only";
|
|
341
|
+
const sectionCount = result.layout?.sections.length ?? 0;
|
|
342
|
+
console.log(
|
|
343
|
+
` ✓ ${url} → ${tag}${result.layout ? ` (${sectionCount} sections)` : ""}`,
|
|
344
|
+
);
|
|
307
345
|
} else {
|
|
308
346
|
console.log(` ✗ ${url} ${result.capture.reason ?? ""}`);
|
|
309
347
|
}
|
|
@@ -339,9 +377,21 @@ async function main(): Promise<void> {
|
|
|
339
377
|
console.log("[extract] wrote:");
|
|
340
378
|
for (const f of written) console.log(` → ${f}`);
|
|
341
379
|
console.log(` → ${join(outDir, "run.json")}`);
|
|
380
|
+
const mirrorDirs = captures.filter((c) => c.mirrorDir).map((c) => c.mirrorDir!);
|
|
381
|
+
if (mirrorDirs.length > 0) {
|
|
382
|
+
console.log("");
|
|
383
|
+
console.log("[extract] layout mirrors:");
|
|
384
|
+
for (const d of mirrorDirs) console.log(` → ${d}/page.tsx`);
|
|
385
|
+
}
|
|
342
386
|
console.log("");
|
|
343
|
-
console.log(`[extract] done. Open ${join(outDir, "REPORT.md")} for the summary.`);
|
|
344
|
-
|
|
387
|
+
console.log(`[extract] done. Open ${join(outDir, "REPORT.md")} for the design-system summary.`);
|
|
388
|
+
if (mirrorDirs.length > 0) {
|
|
389
|
+
console.log(
|
|
390
|
+
`[extract] each mirror folder ships a Next.js page.tsx + MIRROR_NOTES.md.`,
|
|
391
|
+
);
|
|
392
|
+
console.log(`[extract] fill the <TextSlot> / <MediaSlot> placeholders with your own content.`);
|
|
393
|
+
}
|
|
394
|
+
console.log(`[extract] AI handoff: ${join(outDir, "FOR_AI.md")}`);
|
|
345
395
|
}
|
|
346
396
|
|
|
347
397
|
function makeRunId(startedAt: string, name: string | undefined): string {
|
|
@@ -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
|
+
}
|