brandpull 0.1.2

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.
@@ -0,0 +1,583 @@
1
+ import type { BrandingProfile } from "./types"
2
+
3
+ interface PreviewOptions {
4
+ port?: number
5
+ open?: boolean
6
+ }
7
+
8
+ function escapeHtml(value: string): string {
9
+ return value
10
+ .replace(/&/g, "&")
11
+ .replace(/</g, "&lt;")
12
+ .replace(/>/g, "&gt;")
13
+ .replace(/"/g, "&quot;")
14
+ .replace(/'/g, "&#39;")
15
+ }
16
+
17
+ function html(profile: BrandingProfile): string {
18
+ const title = profile.brandName ? `${profile.brandName} Branding` : "Branding Preview"
19
+ return `<!doctype html>
20
+ <html lang="en">
21
+ <head>
22
+ <meta charset="utf-8" />
23
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
24
+ <title>${escapeHtml(title)}</title>
25
+ <script src="https://cdn.tailwindcss.com"></script>
26
+ <style>
27
+ @font-face {
28
+ font-family: "Geist Sans";
29
+ src: url("https://cdn.jsdelivr.net/npm/geist@1.7.0/dist/fonts/geist-sans/Geist-Variable.woff2") format("woff2");
30
+ font-display: swap;
31
+ font-style: normal;
32
+ font-weight: 100 900;
33
+ }
34
+ @font-face {
35
+ font-family: "Geist Mono";
36
+ src: url("https://cdn.jsdelivr.net/npm/geist@1.7.0/dist/fonts/geist-mono/GeistMono-Variable.woff2") format("woff2");
37
+ font-display: swap;
38
+ font-style: normal;
39
+ font-weight: 100 900;
40
+ }
41
+ :root { color-scheme: light; }
42
+ html.preview-dark { color-scheme: dark; }
43
+ body { font-family: "Geist Sans", Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
44
+ code, pre, .font-mono {
45
+ font-family: "Geist Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
46
+ font-variant-numeric: tabular-nums;
47
+ }
48
+ .label-mono {
49
+ font-family: "Geist Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
50
+ font-variant-numeric: tabular-nums;
51
+ letter-spacing: 0;
52
+ text-transform: uppercase;
53
+ }
54
+ .checker {
55
+ background-color: #fff;
56
+ background-image:
57
+ linear-gradient(45deg, #e5e7eb 25%, transparent 25%),
58
+ linear-gradient(-45deg, #e5e7eb 25%, transparent 25%),
59
+ linear-gradient(45deg, transparent 75%, #e5e7eb 75%),
60
+ linear-gradient(-45deg, transparent 75%, #e5e7eb 75%);
61
+ background-size: 18px 18px;
62
+ background-position: 0 0, 0 9px, 9px -9px, -9px 0;
63
+ }
64
+ html.preview-dark .checker {
65
+ background-color: #18181b;
66
+ background-image:
67
+ linear-gradient(45deg, #3f3f46 25%, transparent 25%),
68
+ linear-gradient(-45deg, #3f3f46 25%, transparent 25%),
69
+ linear-gradient(45deg, transparent 75%, #3f3f46 75%),
70
+ linear-gradient(-45deg, transparent 75%, #3f3f46 75%);
71
+ }
72
+ html.preview-dark body,
73
+ html.preview-dark .bg-zinc-50 {
74
+ background-color: #09090b !important;
75
+ color: #f4f4f5;
76
+ }
77
+ html.preview-dark header,
78
+ html.preview-dark section {
79
+ background-color: #09090b !important;
80
+ border-color: #27272a !important;
81
+ }
82
+ html.preview-dark .bg-white {
83
+ background-color: #18181b !important;
84
+ }
85
+ html.preview-dark .bg-zinc-100 {
86
+ background-color: #27272a !important;
87
+ }
88
+ html.preview-dark .border-zinc-100,
89
+ html.preview-dark .border-zinc-200 {
90
+ border-color: #27272a !important;
91
+ }
92
+ html.preview-dark .text-zinc-950,
93
+ html.preview-dark .text-zinc-900,
94
+ html.preview-dark .text-zinc-800,
95
+ html.preview-dark .text-zinc-700 {
96
+ color: #f4f4f5 !important;
97
+ }
98
+ html.preview-dark .text-zinc-600,
99
+ html.preview-dark .text-zinc-500,
100
+ html.preview-dark .text-zinc-400 {
101
+ color: #a1a1aa !important;
102
+ }
103
+ html.preview-dark .hover\\:bg-zinc-50:hover {
104
+ background-color: #27272a !important;
105
+ }
106
+ html.preview-dark [data-tab][aria-pressed="true"] {
107
+ background-color: #f4f4f5 !important;
108
+ border-color: #f4f4f5 !important;
109
+ color: #09090b !important;
110
+ }
111
+ </style>
112
+ </head>
113
+ <body class="min-h-screen bg-zinc-50 text-zinc-950">
114
+ <div id="app"></div>
115
+ <script>
116
+ const state = { data: null, tab: "overview", previewTheme: "light", imageErrors: new Set() };
117
+
118
+ const app = document.getElementById("app");
119
+ const applyPreviewTheme = () => {
120
+ document.documentElement.classList.toggle("preview-dark", state.previewTheme === "dark");
121
+ };
122
+ applyPreviewTheme();
123
+ const esc = (value) => String(value ?? "").replace(/[&<>"']/g, (ch) => ({
124
+ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;"
125
+ }[ch]));
126
+ const swatch = (label, color) => {
127
+ const display = color || "missing";
128
+ return \`
129
+ <div class="flex items-center justify-between gap-3 border-b border-zinc-100 py-2 last:border-b-0">
130
+ <dt class="text-sm text-zinc-500">\${esc(label)}</dt>
131
+ <dd class="flex min-w-0 items-center gap-2">
132
+ <code class="truncate text-sm text-zinc-800">\${esc(display)}</code>
133
+ <span class="h-7 w-7 shrink-0 rounded border border-zinc-200 shadow-sm" style="background:\${esc(color || "transparent")}"></span>
134
+ </dd>
135
+ </div>
136
+ \`;
137
+ };
138
+
139
+ const confidence = (score) => {
140
+ const pct = Math.max(0, Math.min(100, Math.round((score || 0) * 100)));
141
+ const color = pct >= 75 ? "bg-emerald-100 text-emerald-800 border-emerald-200" : pct >= 45 ? "bg-amber-100 text-amber-800 border-amber-200" : "bg-rose-100 text-rose-800 border-rose-200";
142
+ return \`<span class="inline-flex items-center rounded-full border px-2.5 py-1 text-xs font-medium \${color}">\${pct}%</span>\`;
143
+ };
144
+
145
+ const section = (title, body, aside = "") => \`
146
+ <section class="border-t border-zinc-200 bg-white">
147
+ <div class="mx-auto grid max-w-7xl gap-6 px-4 py-6 sm:px-6 lg:grid-cols-[220px_1fr] lg:px-8">
148
+ <div>
149
+ <h2 class="label-mono text-xs font-semibold text-zinc-500">\${esc(title)}</h2>
150
+ \${aside}
151
+ </div>
152
+ <div>\${body}</div>
153
+ </div>
154
+ </section>
155
+ \`;
156
+
157
+ const imgPanel = (label, src, options = {}) => {
158
+ const id = label.toLowerCase().replace(/\\W+/g, "-");
159
+ const key = options.key || id;
160
+ const failed = state.imageErrors.has(id);
161
+ const href = src && !src.startsWith("data:") ? \`<a class="text-xs text-zinc-500 underline underline-offset-2" href="\${esc(src)}" target="_blank" rel="noreferrer">open source</a>\` : "";
162
+ const download = src ? \`
163
+ <a class="inline-flex h-7 w-7 items-center justify-center rounded-md border border-zinc-200 bg-white text-zinc-500 hover:border-zinc-300 hover:text-zinc-900" href="/download?image=\${encodeURIComponent(key)}" title="Download \${esc(label)}" aria-label="Download \${esc(label)}">
164
+ <svg viewBox="0 0 24 24" class="h-4 w-4" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
165
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
166
+ <path d="M7 10l5 5 5-5"></path>
167
+ <path d="M12 15V3"></path>
168
+ </svg>
169
+ </a>
170
+ \` : "";
171
+ const actions = src ? \`<div class="flex items-center gap-2">\${href}\${download}</div>\` : "";
172
+ const body = !src
173
+ ? \`<div class="flex h-48 items-center justify-center text-sm text-zinc-400">No \${esc(label)} detected</div>\`
174
+ : failed
175
+ ? \`<div class="flex h-48 items-center justify-center px-4 text-center text-sm text-rose-600">Image failed to load</div>\`
176
+ : \`<div class="checker flex h-48 items-center justify-center rounded-b-md p-5">
177
+ <img src="\${esc(src)}" alt="\${esc(label)}" class="\${options.wide ? "max-h-full max-w-full" : "max-h-28 max-w-56"} object-contain" data-image-id="\${id}" />
178
+ </div>\`;
179
+ return \`
180
+ <div class="overflow-hidden rounded-md border border-zinc-200 bg-white">
181
+ <div class="flex min-h-12 items-center justify-between gap-3 border-b border-zinc-200 bg-zinc-100 px-4">
182
+ <h3 class="label-mono text-xs font-semibold text-zinc-700">\${esc(label)}</h3>
183
+ \${actions}
184
+ </div>
185
+ \${body}
186
+ </div>
187
+ \`;
188
+ };
189
+
190
+ const componentStyle = (label, style) => {
191
+ if (!style) return "";
192
+ const bg = style.background || "transparent";
193
+ const color = style.textColor || "#111827";
194
+ const border = style.borderColor || "transparent";
195
+ const radius = style.borderRadius || "6px";
196
+ const shadow = style.shadow && style.shadow !== "none" ? style.shadow : "none";
197
+ return \`
198
+ <div class="flex flex-wrap items-center gap-4 rounded-md border border-zinc-200 bg-white p-4">
199
+ <button class="min-h-10 max-w-full truncate px-4 py-2 text-sm font-medium" style="background:\${esc(bg)}; color:\${esc(color)}; border:1px solid \${esc(border)}; border-radius:\${esc(radius)}; box-shadow:\${esc(shadow)}">
200
+ \${esc(style.text || label)}
201
+ </button>
202
+ <dl class="grid min-w-0 flex-1 grid-cols-1 gap-x-5 gap-y-1 text-sm text-zinc-600 sm:grid-cols-3">
203
+ <div><dt class="text-zinc-400">Background</dt><dd><code>\${esc(bg)}</code></dd></div>
204
+ <div><dt class="text-zinc-400">Text</dt><dd><code>\${esc(color)}</code></dd></div>
205
+ <div><dt class="text-zinc-400">Radius</dt><dd><code>\${esc(radius)}</code></dd></div>
206
+ </dl>
207
+ </div>
208
+ \`;
209
+ };
210
+
211
+ const overview = () => {
212
+ const data = state.data || {};
213
+ const colors = data.colors || {};
214
+ const images = data.images || {};
215
+ const typography = data.typography || {};
216
+ const components = data.components || {};
217
+ const fonts = data.fonts || [];
218
+ const diagnostics = data.diagnostics || {};
219
+ const errors = diagnostics.errors || [];
220
+ return \`
221
+ \${errors.length ? section("Diagnostics", \`
222
+ <div class="rounded-md border border-amber-200 bg-amber-50 p-4">
223
+ <h3 class="text-sm font-semibold text-amber-900">Extraction diagnostics</h3>
224
+ <ul class="mt-2 space-y-1 text-sm text-amber-800">
225
+ \${errors.map((err) => \`<li><code>\${esc(err.context)}</code>: \${esc(err.message)}</li>\`).join("")}
226
+ </ul>
227
+ </div>\`) : ""}
228
+
229
+ \${section("Images", \`
230
+ <div class="grid gap-4 lg:grid-cols-3">
231
+ \${imgPanel("Logo", data.logo || images.logo, { key: "logo" })}
232
+ \${imgPanel("Favicon", images.favicon, { key: "favicon" })}
233
+ \${imgPanel("OG Image", images.ogImage, { wide: true, key: "ogImage" })}
234
+ </div>
235
+ \`)}
236
+
237
+ \${section("Components", \`
238
+ <div class="space-y-3">
239
+ \${componentStyle("Primary", components.buttonPrimary)}
240
+ \${componentStyle("Secondary", components.buttonSecondary)}
241
+ \${componentStyle("Input", components.input)}
242
+ \${!components.buttonPrimary && !components.buttonSecondary && !components.input ? '<div class="rounded-md border border-zinc-200 bg-white p-4 text-sm text-zinc-500">No component styles detected.</div>' : ""}
243
+ </div>
244
+ \`)}
245
+
246
+ \${section("Tokens", \`
247
+ <div class="grid gap-6 lg:grid-cols-3">
248
+ <div class="rounded-md border border-zinc-200 bg-white p-4">
249
+ <h3 class="label-mono mb-2 text-xs font-semibold text-zinc-800">Colors</h3>
250
+ <dl>
251
+ \${swatch("Primary", colors.primary)}
252
+ \${swatch("Secondary", colors.secondary)}
253
+ \${swatch("Accent", colors.accent)}
254
+ \${swatch("Background", colors.background)}
255
+ \${swatch("Text", colors.textPrimary)}
256
+ \${swatch("Link", colors.link)}
257
+ </dl>
258
+ </div>
259
+ <div class="rounded-md border border-zinc-200 bg-white p-4">
260
+ <h3 class="label-mono mb-3 text-xs font-semibold text-zinc-800">Fonts</h3>
261
+ <div class="space-y-2">
262
+ \${fonts.length ? fonts.map((font) => \`
263
+ <div class="flex items-baseline justify-between gap-4">
264
+ <span class="truncate text-sm font-medium text-zinc-800">\${esc(font.family)}</span>
265
+ <span class="shrink-0 text-xs text-zinc-500">\${esc(font.role || (font.count ? font.count + "x" : ""))}</span>
266
+ </div>
267
+ \`).join("") : '<p class="text-sm text-zinc-500">No custom fonts detected.</p>'}
268
+ </div>
269
+ </div>
270
+ <div class="rounded-md border border-zinc-200 bg-white p-4">
271
+ <h3 class="label-mono mb-3 text-xs font-semibold text-zinc-800">Typography</h3>
272
+ <dl class="space-y-2 text-sm">
273
+ <div class="flex justify-between gap-3"><dt class="text-zinc-500">Primary</dt><dd class="truncate font-mono">\${esc(typography.fontFamilies?.primary || "")}</dd></div>
274
+ <div class="flex justify-between gap-3"><dt class="text-zinc-500">Heading</dt><dd class="truncate font-mono">\${esc(typography.fontFamilies?.heading || "")}</dd></div>
275
+ <div class="flex justify-between gap-3"><dt class="text-zinc-500">H1</dt><dd class="font-mono">\${esc(typography.fontSizes?.h1 || "")}</dd></div>
276
+ <div class="flex justify-between gap-3"><dt class="text-zinc-500">H2</dt><dd class="font-mono">\${esc(typography.fontSizes?.h2 || "")}</dd></div>
277
+ <div class="flex justify-between gap-3"><dt class="text-zinc-500">Body</dt><dd class="font-mono">\${esc(typography.fontSizes?.body || "")}</dd></div>
278
+ </dl>
279
+ </div>
280
+ </div>
281
+ \`)}
282
+
283
+ \${section("Spacing", \`
284
+ <div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
285
+ <div class="rounded-md border border-zinc-200 bg-white p-4"><div class="label-mono text-xs text-zinc-500">Base Unit</div><div class="mt-1 text-2xl font-semibold text-zinc-900">\${esc(data.spacing?.baseUnit ?? "")}</div></div>
286
+ <div class="rounded-md border border-zinc-200 bg-white p-4"><div class="label-mono text-xs text-zinc-500">Border Radius</div><div class="mt-1 text-2xl font-semibold text-zinc-900">\${esc(data.spacing?.borderRadius ?? "")}</div></div>
287
+ <div class="rounded-md border border-zinc-200 bg-white p-4"><div class="label-mono text-xs text-zinc-500">Theme</div><div class="mt-1 text-2xl font-semibold text-zinc-900">\${esc(data.colorScheme || "")}</div></div>
288
+ <div class="rounded-md border border-zinc-200 bg-white p-4"><div class="label-mono text-xs text-zinc-500">Overall Confidence</div><div class="mt-2">\${confidence(data.confidence?.overall)}</div></div>
289
+ </div>
290
+ \`)}
291
+ \`;
292
+ };
293
+
294
+ const rawPanel = () => \`
295
+ \${section("JSON", \`
296
+ <div class="overflow-hidden rounded-md border border-zinc-200 bg-zinc-950">
297
+ <div class="flex items-center justify-between border-b border-zinc-800 px-4 py-3">
298
+ <h3 class="label-mono text-xs font-semibold text-zinc-100">branding.json</h3>
299
+ <button id="copy-json" class="rounded-md border border-zinc-700 px-3 py-1.5 text-sm text-zinc-100 hover:bg-zinc-900">Copy JSON</button>
300
+ </div>
301
+ <pre class="max-h-[70vh] overflow-auto p-4 text-xs leading-5 text-zinc-100"><code>\${esc(JSON.stringify(state.data, null, 2))}</code></pre>
302
+ </div>
303
+ \`)}
304
+ \`;
305
+
306
+ const debugPanel = () => {
307
+ const debug = state.data?.debug || {};
308
+ const candidates = debug.logoCandidates || [];
309
+ const buttons = debug.buttons || [];
310
+ return \`
311
+ \${section("Logo Candidates", \`
312
+ <div class="grid gap-3">
313
+ \${candidates.length ? candidates.map((item, idx) => \`
314
+ <div class="grid gap-4 rounded-md border border-zinc-200 bg-white p-4 lg:grid-cols-[90px_1fr]">
315
+ <div class="checker flex h-20 w-20 items-center justify-center rounded border border-zinc-200 p-2">
316
+ \${item.src ? \`<img src="\${esc(item.src)}" class="max-h-full max-w-full object-contain" alt="">\` : ""}
317
+ </div>
318
+ <div class="min-w-0">
319
+ <div class="flex flex-wrap items-center gap-2">
320
+ <span class="text-sm font-semibold text-zinc-900">#\${idx}</span>
321
+ <span class="rounded-full bg-zinc-100 px-2 py-0.5 text-xs text-zinc-600">\${esc(item.location)}</span>
322
+ <span class="rounded-full bg-zinc-100 px-2 py-0.5 text-xs text-zinc-600">\${item.isVisible ? "visible" : "hidden"}</span>
323
+ </div>
324
+ <p class="mt-2 truncate text-sm text-zinc-700">\${esc(item.alt || item.ariaLabel || item.title || "No label")}</p>
325
+ <p class="mt-1 truncate font-mono text-xs text-zinc-500">\${esc(item.src)}</p>
326
+ <p class="mt-2 text-xs text-zinc-500">\${Math.round(item.position?.width || 0)}x\${Math.round(item.position?.height || 0)} - href: \${esc(item.href || "none")}</p>
327
+ </div>
328
+ </div>
329
+ \`).join("") : '<div class="rounded-md border border-zinc-200 bg-white p-4 text-sm text-zinc-500">No logo candidates available. Run with --raw to include debug candidates.</div>'}
330
+ </div>
331
+ \`)}
332
+ \${section("Button Candidates", \`
333
+ <div class="grid gap-3">
334
+ \${buttons.length ? buttons.map((button) => componentStyle("#" + button.index + " " + (button.text || "Button"), button)).join("") : '<div class="rounded-md border border-zinc-200 bg-white p-4 text-sm text-zinc-500">No button candidates available. Run with --raw to include debug candidates.</div>'}
335
+ </div>
336
+ \`)}
337
+ \`;
338
+ };
339
+
340
+ const render = () => {
341
+ applyPreviewTheme();
342
+ const data = state.data || {};
343
+ const diagnostics = data.diagnostics || {};
344
+ app.innerHTML = \`
345
+ <header class="border-b border-zinc-200 bg-white">
346
+ <div class="mx-auto flex max-w-7xl flex-col gap-4 px-4 py-5 sm:px-6 lg:px-8">
347
+ <div class="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
348
+ <div class="min-w-0">
349
+ <div class="flex flex-wrap items-center gap-2">
350
+ <h1 class="truncate text-2xl font-semibold text-zinc-950">\${esc(data.brandName || "Branding Preview")}</h1>
351
+ <span class="rounded-full border border-zinc-200 bg-zinc-100 px-2.5 py-1 text-xs font-medium text-zinc-700">\${esc(data.colorScheme || "unknown")}</span>
352
+ </div>
353
+ <p class="mt-1 truncate text-sm text-zinc-500">\${esc(data.finalUrl || data.url || "")}</p>
354
+ </div>
355
+ </div>
356
+ <nav class="flex flex-wrap gap-2" aria-label="Preview tabs">
357
+ \${["overview", "debug", "json"].map((tab) => \`
358
+ <button class="rounded-md border px-3 py-2 text-sm font-medium \${state.tab === tab ? "border-zinc-950 bg-zinc-950 text-white" : "border-zinc-200 bg-white text-zinc-700 hover:bg-zinc-50"}" data-tab="\${tab}" aria-pressed="\${state.tab === tab}">\${tab[0].toUpperCase() + tab.slice(1)}</button>
359
+ \`).join("")}
360
+ <button class="rounded-md border border-zinc-200 bg-white px-3 py-2 text-sm font-medium text-zinc-700 hover:bg-zinc-50" data-theme-toggle>Viewer: \${state.previewTheme === "dark" ? "Dark" : "Light"}</button>
361
+ <a class="rounded-md border border-zinc-200 bg-white px-3 py-2 text-sm font-medium text-zinc-700 hover:bg-zinc-50" href="/branding.json" target="_blank" rel="noreferrer">Open JSON</a>
362
+ </nav>
363
+ \${diagnostics.llm?.error ? \`<div class="rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800">LLM enhancement did not run: \${esc(diagnostics.llm.error)}</div>\` : ""}
364
+ </div>
365
+ </header>
366
+ <main>\${state.tab === "json" ? rawPanel() : state.tab === "debug" ? debugPanel() : overview()}</main>
367
+ \`;
368
+
369
+ app.querySelectorAll("[data-tab]").forEach((button) => {
370
+ button.addEventListener("click", () => {
371
+ state.tab = button.getAttribute("data-tab");
372
+ render();
373
+ });
374
+ });
375
+ app.querySelector("[data-theme-toggle]")?.addEventListener("click", () => {
376
+ state.previewTheme = state.previewTheme === "dark" ? "light" : "dark";
377
+ render();
378
+ });
379
+ app.querySelectorAll("img[data-image-id]").forEach((img) => {
380
+ img.addEventListener("error", () => {
381
+ state.imageErrors.add(img.getAttribute("data-image-id"));
382
+ render();
383
+ }, { once: true });
384
+ });
385
+ document.getElementById("copy-json")?.addEventListener("click", async () => {
386
+ await navigator.clipboard.writeText(JSON.stringify(state.data, null, 2));
387
+ });
388
+ };
389
+
390
+ fetch("/branding.json")
391
+ .then((response) => {
392
+ if (!response.ok) throw new Error("HTTP " + response.status);
393
+ return response.json();
394
+ })
395
+ .then((json) => {
396
+ state.data = json;
397
+ state.previewTheme = "light";
398
+ render();
399
+ })
400
+ .catch((error) => {
401
+ app.innerHTML = \`<div class="mx-auto max-w-3xl p-8"><div class="rounded-md border border-rose-200 bg-rose-50 p-4 text-rose-800">Failed to load branding JSON: \${esc(error.message)}</div></div>\`;
402
+ });
403
+ </script>
404
+ </body>
405
+ </html>`
406
+ }
407
+
408
+ function imageUrl(profile: BrandingProfile, key: string): string | null {
409
+ const images = profile.images ?? {}
410
+ if (key === "logo") return profile.logo || images.logo || null
411
+ if (key === "favicon") return images.favicon || null
412
+ if (key === "ogImage") return images.ogImage || null
413
+ return null
414
+ }
415
+
416
+ function slug(value: string): string {
417
+ return (
418
+ value
419
+ .toLowerCase()
420
+ .replace(/^https?:\/\//, "")
421
+ .replace(/^www\./, "")
422
+ .replace(/[^a-z0-9]+/g, "-")
423
+ .replace(/^-|-$/g, "") || "brand"
424
+ )
425
+ }
426
+
427
+ function profileSlug(profile: BrandingProfile): string {
428
+ if (profile.brandName) return slug(profile.brandName)
429
+ const url = profile.finalUrl || profile.url
430
+ if (!url) return "brand"
431
+ try {
432
+ return slug(new URL(url).hostname)
433
+ } catch {
434
+ return slug(url)
435
+ }
436
+ }
437
+
438
+ function extensionFor(contentType: string, src: string): string {
439
+ const mime = contentType.toLowerCase().split(";")[0]?.trim()
440
+ if (mime === "image/svg+xml") return "svg"
441
+ if (mime === "image/png") return "png"
442
+ if (mime === "image/jpeg") return "jpg"
443
+ if (mime === "image/webp") return "webp"
444
+ if (mime === "image/gif") return "gif"
445
+ if (mime === "image/x-icon" || mime === "image/vnd.microsoft.icon") return "ico"
446
+
447
+ try {
448
+ const ext = new URL(src).pathname.match(/\.([a-z0-9]{2,5})$/i)?.[1]
449
+ if (ext && !["html", "php", "aspx"].includes(ext.toLowerCase())) return ext.toLowerCase()
450
+ } catch {
451
+ const ext = src.match(/\.([a-z0-9]{2,5})(?:$|[?#])/i)?.[1]
452
+ if (ext) return ext.toLowerCase()
453
+ }
454
+ return "bin"
455
+ }
456
+
457
+ function imageFilename(profile: BrandingProfile, key: string, contentType: string, src: string): string {
458
+ const names: Record<string, string> = {
459
+ logo: "logo",
460
+ favicon: "favicon",
461
+ ogImage: "og-image",
462
+ }
463
+ return `${profileSlug(profile)}-${names[key] ?? slug(key)}.${extensionFor(contentType, src)}`
464
+ }
465
+
466
+ function dataUrlResponse(profile: BrandingProfile, key: string, src: string): Response {
467
+ const comma = src.indexOf(",")
468
+ if (comma === -1) return new Response("Bad data URL", { status: 400 })
469
+ const meta = src.slice("data:".length, comma)
470
+ const contentType = meta.split(";")[0] || "application/octet-stream"
471
+ const payload = src.slice(comma + 1)
472
+ const body = meta.includes(";base64") ? Buffer.from(payload, "base64") : Buffer.from(decodeURIComponent(payload))
473
+ return new Response(body, {
474
+ headers: {
475
+ "content-type": contentType,
476
+ "content-disposition": `attachment; filename="${imageFilename(profile, key, contentType, src)}"`,
477
+ "cache-control": "no-store",
478
+ },
479
+ })
480
+ }
481
+
482
+ async function downloadImage(profile: BrandingProfile, request: Request): Promise<Response> {
483
+ const url = new URL(request.url)
484
+ const key = url.searchParams.get("image") ?? ""
485
+ const src = imageUrl(profile, key)
486
+ if (!src) return new Response("Image not found", { status: 404 })
487
+ if (src.startsWith("data:")) return dataUrlResponse(profile, key, src)
488
+
489
+ let resolved = src
490
+ try {
491
+ resolved = new URL(src, profile.finalUrl || profile.url).href
492
+ } catch {
493
+ return new Response("Bad image URL", { status: 400 })
494
+ }
495
+
496
+ const response = await fetch(resolved, {
497
+ headers: {
498
+ "user-agent": "brandpull-preview/0.1",
499
+ },
500
+ })
501
+ if (!response.ok) return new Response(`Could not fetch image: HTTP ${response.status}`, { status: 502 })
502
+
503
+ const contentType = response.headers.get("content-type") || "application/octet-stream"
504
+ return new Response(response.body, {
505
+ headers: {
506
+ "content-type": contentType,
507
+ "content-disposition": `attachment; filename="${imageFilename(profile, key, contentType, resolved)}"`,
508
+ "cache-control": "no-store",
509
+ },
510
+ })
511
+ }
512
+
513
+ async function startServer(
514
+ profile: BrandingProfile,
515
+ port: number,
516
+ attempts = 20,
517
+ ): Promise<{ server: ReturnType<typeof Bun.serve>; url: string }> {
518
+ for (let offset = 0; offset < attempts; offset++) {
519
+ const candidatePort = port + offset
520
+ try {
521
+ const server = Bun.serve({
522
+ port: candidatePort,
523
+ async fetch(request) {
524
+ const url = new URL(request.url)
525
+ if (url.pathname === "/branding.json") {
526
+ return Response.json(profile, {
527
+ headers: {
528
+ "cache-control": "no-store",
529
+ },
530
+ })
531
+ }
532
+ if (url.pathname === "/download") {
533
+ return downloadImage(profile, request)
534
+ }
535
+ if (url.pathname === "/" || url.pathname === "/index.html") {
536
+ return new Response(html(profile), {
537
+ headers: {
538
+ "content-type": "text/html; charset=utf-8",
539
+ "cache-control": "no-store",
540
+ },
541
+ })
542
+ }
543
+ return new Response("Not found", { status: 404 })
544
+ },
545
+ })
546
+ return { server, url: `http://localhost:${candidatePort}` }
547
+ } catch (error) {
548
+ if (!isAddressInUse(error) || offset === attempts - 1) throw error
549
+ process.stderr.write(` Port ${candidatePort} is in use, trying ${candidatePort + 1}...\n`)
550
+ }
551
+ }
552
+ throw new Error("No available preview port found")
553
+ }
554
+
555
+ function isAddressInUse(error: unknown): boolean {
556
+ if (error && typeof error === "object" && "code" in error && error.code === "EADDRINUSE") return true
557
+ const message = error instanceof Error ? error.message : String(error)
558
+ return message.includes("EADDRINUSE") || message.includes("Address already in use")
559
+ }
560
+
561
+ export async function serveBrandingPreview(profile: BrandingProfile, options: PreviewOptions = {}): Promise<void> {
562
+ const { server, url } = await startServer(profile, options.port ?? 4177)
563
+ const label = options.open === false ? "Preview server running" : "Opening preview"
564
+ process.stderr.write(` ${label}: ${url}\n`)
565
+ process.stderr.write(" Press Ctrl+C to stop the preview server.\n")
566
+
567
+ if (options.open !== false) {
568
+ Bun.spawn(["open", url], {
569
+ stdout: "ignore",
570
+ stderr: "ignore",
571
+ })
572
+ }
573
+
574
+ await new Promise<void>((resolve) => {
575
+ const stop = () => {
576
+ server.stop(true)
577
+ process.stderr.write("\n Preview server stopped.\n")
578
+ resolve()
579
+ }
580
+ process.once("SIGINT", stop)
581
+ process.once("SIGTERM", stop)
582
+ })
583
+ }