@easybits.cloud/html-tailwind-generator 0.1.0
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/LICENSE +131 -0
- package/README.md +178 -0
- package/package.json +50 -0
- package/src/buildHtml.ts +78 -0
- package/src/components/Canvas.tsx +162 -0
- package/src/components/CodeEditor.tsx +239 -0
- package/src/components/FloatingToolbar.tsx +350 -0
- package/src/components/SectionList.tsx +217 -0
- package/src/components/index.ts +4 -0
- package/src/deploy.ts +73 -0
- package/src/generate.ts +274 -0
- package/src/iframeScript.ts +261 -0
- package/src/images/enrichImages.ts +127 -0
- package/src/images/index.ts +2 -0
- package/src/images/pexels.ts +27 -0
- package/src/index.ts +57 -0
- package/src/refine.ts +115 -0
- package/src/themes.ts +204 -0
- package/src/types.ts +30 -0
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { useRef, useState } from "react";
|
|
2
|
+
import type { Section3 } from "../types";
|
|
3
|
+
import { LANDING_THEMES, type CustomColors } from "../themes";
|
|
4
|
+
|
|
5
|
+
interface SectionListProps {
|
|
6
|
+
sections: Section3[];
|
|
7
|
+
selectedSectionId: string | null;
|
|
8
|
+
theme: string;
|
|
9
|
+
customColors?: CustomColors;
|
|
10
|
+
onThemeChange: (themeId: string) => void;
|
|
11
|
+
onCustomColorChange?: (colors: Partial<CustomColors>) => void;
|
|
12
|
+
onSelect: (id: string) => void;
|
|
13
|
+
onOpenCode: (id: string) => void;
|
|
14
|
+
onReorder: (fromIndex: number, toIndex: number) => void;
|
|
15
|
+
onDelete: (id: string) => void;
|
|
16
|
+
onRename: (id: string, label: string) => void;
|
|
17
|
+
onAdd: () => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function SectionList({
|
|
21
|
+
sections,
|
|
22
|
+
selectedSectionId,
|
|
23
|
+
theme,
|
|
24
|
+
customColors,
|
|
25
|
+
onThemeChange,
|
|
26
|
+
onCustomColorChange,
|
|
27
|
+
onSelect,
|
|
28
|
+
onOpenCode,
|
|
29
|
+
onReorder,
|
|
30
|
+
onDelete,
|
|
31
|
+
onRename,
|
|
32
|
+
onAdd,
|
|
33
|
+
}: SectionListProps) {
|
|
34
|
+
const sorted = [...sections].sort((a, b) => a.order - b.order);
|
|
35
|
+
const colorInputRef = useRef<HTMLInputElement>(null);
|
|
36
|
+
const [editingId, setEditingId] = useState<string | null>(null);
|
|
37
|
+
const [editingLabel, setEditingLabel] = useState("");
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<div className="w-56 shrink-0 flex flex-col bg-white border-r-2 border-gray-200 overflow-y-auto">
|
|
41
|
+
<div className="p-3 border-b border-gray-200">
|
|
42
|
+
<h3 className="text-xs font-black uppercase tracking-wider text-gray-500 mb-2">
|
|
43
|
+
Tema
|
|
44
|
+
</h3>
|
|
45
|
+
<div className="flex gap-1.5 flex-wrap">
|
|
46
|
+
{LANDING_THEMES.map((t) => (
|
|
47
|
+
<button
|
|
48
|
+
key={t.id}
|
|
49
|
+
onClick={() => onThemeChange(t.id)}
|
|
50
|
+
title={t.label}
|
|
51
|
+
className={`w-6 h-6 rounded-full border-2 transition-all ${
|
|
52
|
+
theme === t.id
|
|
53
|
+
? "border-black scale-110 shadow-sm"
|
|
54
|
+
: "border-gray-300 hover:border-gray-400"
|
|
55
|
+
}`}
|
|
56
|
+
style={{ backgroundColor: t.colors.primary }}
|
|
57
|
+
/>
|
|
58
|
+
))}
|
|
59
|
+
{/* Custom color picker */}
|
|
60
|
+
<button
|
|
61
|
+
onClick={() => colorInputRef.current?.click()}
|
|
62
|
+
title="Color personalizado"
|
|
63
|
+
className={`w-6 h-6 rounded-full border-2 transition-all relative overflow-hidden ${
|
|
64
|
+
theme === "custom"
|
|
65
|
+
? "border-black scale-110 shadow-sm"
|
|
66
|
+
: "border-gray-300 hover:border-gray-400"
|
|
67
|
+
}`}
|
|
68
|
+
style={theme === "custom" && customColors?.primary ? { backgroundColor: customColors.primary } : undefined}
|
|
69
|
+
>
|
|
70
|
+
{theme !== "custom" && (
|
|
71
|
+
<span className="absolute inset-0 rounded-full"
|
|
72
|
+
style={{ background: "conic-gradient(#ef4444, #eab308, #22c55e, #3b82f6, #a855f7, #ef4444)" }}
|
|
73
|
+
/>
|
|
74
|
+
)}
|
|
75
|
+
</button>
|
|
76
|
+
<input
|
|
77
|
+
ref={colorInputRef}
|
|
78
|
+
type="color"
|
|
79
|
+
value={customColors?.primary || "#6366f1"}
|
|
80
|
+
onChange={(e) => onCustomColorChange?.({ primary: e.target.value })}
|
|
81
|
+
className="sr-only"
|
|
82
|
+
/>
|
|
83
|
+
</div>
|
|
84
|
+
{/* Multi-color pickers when custom theme is active */}
|
|
85
|
+
{theme === "custom" && (
|
|
86
|
+
<div className="flex items-center gap-2 mt-2">
|
|
87
|
+
{([
|
|
88
|
+
{ key: "primary" as const, label: "Pri", fallback: "#6366f1" },
|
|
89
|
+
{ key: "secondary" as const, label: "Sec", fallback: "#f59e0b" },
|
|
90
|
+
{ key: "accent" as const, label: "Acc", fallback: "#06b6d4" },
|
|
91
|
+
{ key: "surface" as const, label: "Sur", fallback: "#ffffff" },
|
|
92
|
+
]).map((c) => (
|
|
93
|
+
<label key={c.key} className="flex flex-col items-center gap-0.5 cursor-pointer">
|
|
94
|
+
<input
|
|
95
|
+
type="color"
|
|
96
|
+
value={customColors?.[c.key] || c.fallback}
|
|
97
|
+
onChange={(e) => onCustomColorChange?.({ [c.key]: e.target.value })}
|
|
98
|
+
className="w-5 h-5 rounded border border-gray-300 cursor-pointer p-0 [&::-webkit-color-swatch-wrapper]:p-0 [&::-webkit-color-swatch]:border-none [&::-webkit-color-swatch]:rounded"
|
|
99
|
+
/>
|
|
100
|
+
<span className="text-[9px] font-bold text-gray-400 uppercase">{c.label}</span>
|
|
101
|
+
</label>
|
|
102
|
+
))}
|
|
103
|
+
</div>
|
|
104
|
+
)}
|
|
105
|
+
</div>
|
|
106
|
+
<div className="p-3 border-b border-gray-200">
|
|
107
|
+
<h3 className="text-xs font-black uppercase tracking-wider text-gray-500">
|
|
108
|
+
Secciones
|
|
109
|
+
</h3>
|
|
110
|
+
</div>
|
|
111
|
+
|
|
112
|
+
<div className="flex-1 overflow-y-auto py-1">
|
|
113
|
+
{sorted.map((section, i) => (
|
|
114
|
+
<div
|
|
115
|
+
key={section.id}
|
|
116
|
+
onClick={() => onSelect(section.id)}
|
|
117
|
+
className={`group flex items-center gap-2 px-3 py-2 cursor-pointer transition-colors ${
|
|
118
|
+
selectedSectionId === section.id
|
|
119
|
+
? "bg-blue-50 border-l-2 border-blue-500"
|
|
120
|
+
: "hover:bg-gray-50 border-l-2 border-transparent"
|
|
121
|
+
}`}
|
|
122
|
+
>
|
|
123
|
+
<span className="text-[10px] font-mono text-gray-400 w-4 text-right">
|
|
124
|
+
{i + 1}
|
|
125
|
+
</span>
|
|
126
|
+
{editingId === section.id ? (
|
|
127
|
+
<input
|
|
128
|
+
type="text"
|
|
129
|
+
value={editingLabel}
|
|
130
|
+
onChange={(e) => setEditingLabel(e.target.value)}
|
|
131
|
+
onBlur={() => {
|
|
132
|
+
if (editingLabel.trim()) onRename(section.id, editingLabel.trim());
|
|
133
|
+
setEditingId(null);
|
|
134
|
+
}}
|
|
135
|
+
onKeyDown={(e) => {
|
|
136
|
+
if (e.key === "Enter") {
|
|
137
|
+
if (editingLabel.trim()) onRename(section.id, editingLabel.trim());
|
|
138
|
+
setEditingId(null);
|
|
139
|
+
} else if (e.key === "Escape") {
|
|
140
|
+
setEditingId(null);
|
|
141
|
+
}
|
|
142
|
+
}}
|
|
143
|
+
className="text-sm font-bold flex-1 min-w-0 bg-transparent border-b border-blue-500 outline-none px-0 py-0"
|
|
144
|
+
autoFocus
|
|
145
|
+
onClick={(e) => e.stopPropagation()}
|
|
146
|
+
/>
|
|
147
|
+
) : (
|
|
148
|
+
<span
|
|
149
|
+
className="text-sm font-bold truncate flex-1"
|
|
150
|
+
onDoubleClick={(e) => {
|
|
151
|
+
e.stopPropagation();
|
|
152
|
+
setEditingId(section.id);
|
|
153
|
+
setEditingLabel(section.label);
|
|
154
|
+
}}
|
|
155
|
+
>
|
|
156
|
+
{section.label}
|
|
157
|
+
</span>
|
|
158
|
+
)}
|
|
159
|
+
<div className="opacity-0 group-hover:opacity-100 flex gap-0.5">
|
|
160
|
+
<button
|
|
161
|
+
onClick={(e) => {
|
|
162
|
+
e.stopPropagation();
|
|
163
|
+
onOpenCode(section.id);
|
|
164
|
+
}}
|
|
165
|
+
className="w-5 h-5 flex items-center justify-center rounded text-gray-400 hover:text-gray-700 hover:bg-gray-200"
|
|
166
|
+
title="Editar HTML"
|
|
167
|
+
>
|
|
168
|
+
<svg className="w-3 h-3" viewBox="0 0 16 16" fill="currentColor"><path d="M5.854 4.854a.5.5 0 1 0-.708-.708l-3.5 3.5a.5.5 0 0 0 0 .708l3.5 3.5a.5.5 0 0 0 .708-.708L2.707 8l3.147-3.146zm4.292 0a.5.5 0 0 1 .708-.708l3.5 3.5a.5.5 0 0 1 0 .708l-3.5 3.5a.5.5 0 0 1-.708-.708L13.293 8l-3.147-3.146z"/></svg>
|
|
169
|
+
</button>
|
|
170
|
+
{i > 0 && (
|
|
171
|
+
<button
|
|
172
|
+
onClick={(e) => {
|
|
173
|
+
e.stopPropagation();
|
|
174
|
+
onReorder(i, i - 1);
|
|
175
|
+
}}
|
|
176
|
+
className="w-5 h-5 flex items-center justify-center rounded text-gray-400 hover:text-gray-700 hover:bg-gray-200 text-[10px]"
|
|
177
|
+
>
|
|
178
|
+
↑
|
|
179
|
+
</button>
|
|
180
|
+
)}
|
|
181
|
+
{i < sorted.length - 1 && (
|
|
182
|
+
<button
|
|
183
|
+
onClick={(e) => {
|
|
184
|
+
e.stopPropagation();
|
|
185
|
+
onReorder(i, i + 1);
|
|
186
|
+
}}
|
|
187
|
+
className="w-5 h-5 flex items-center justify-center rounded text-gray-400 hover:text-gray-700 hover:bg-gray-200 text-[10px]"
|
|
188
|
+
>
|
|
189
|
+
↓
|
|
190
|
+
</button>
|
|
191
|
+
)}
|
|
192
|
+
<button
|
|
193
|
+
onClick={(e) => {
|
|
194
|
+
e.stopPropagation();
|
|
195
|
+
onDelete(section.id);
|
|
196
|
+
}}
|
|
197
|
+
className="w-5 h-5 flex items-center justify-center rounded text-gray-400 hover:text-red-600 hover:bg-red-50 text-[10px]"
|
|
198
|
+
title="Eliminar seccion"
|
|
199
|
+
>
|
|
200
|
+
✕
|
|
201
|
+
</button>
|
|
202
|
+
</div>
|
|
203
|
+
</div>
|
|
204
|
+
))}
|
|
205
|
+
</div>
|
|
206
|
+
|
|
207
|
+
<div className="p-3 border-t border-gray-200">
|
|
208
|
+
<button
|
|
209
|
+
onClick={onAdd}
|
|
210
|
+
className="w-full text-center py-2 text-sm font-bold text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
|
|
211
|
+
>
|
|
212
|
+
+ Agregar seccion
|
|
213
|
+
</button>
|
|
214
|
+
</div>
|
|
215
|
+
</div>
|
|
216
|
+
);
|
|
217
|
+
}
|
package/src/deploy.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { buildDeployHtml } from "./buildHtml";
|
|
2
|
+
import type { Section3 } from "./types";
|
|
3
|
+
import type { CustomColors } from "./themes";
|
|
4
|
+
|
|
5
|
+
export interface DeployToS3Options {
|
|
6
|
+
/** The sections to deploy */
|
|
7
|
+
sections: Section3[];
|
|
8
|
+
/** Theme ID */
|
|
9
|
+
theme?: string;
|
|
10
|
+
/** Custom colors (when theme is "custom") */
|
|
11
|
+
customColors?: CustomColors;
|
|
12
|
+
/** S3-compatible upload function. Receives the HTML string, returns the URL */
|
|
13
|
+
upload: (html: string) => Promise<string>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Deploy a landing page to any S3-compatible storage.
|
|
18
|
+
* The consumer provides their own upload function.
|
|
19
|
+
*/
|
|
20
|
+
export async function deployToS3(options: DeployToS3Options): Promise<string> {
|
|
21
|
+
const { sections, theme, customColors, upload } = options;
|
|
22
|
+
const html = buildDeployHtml(sections, theme, customColors);
|
|
23
|
+
return upload(html);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface DeployToEasyBitsOptions {
|
|
27
|
+
/** EasyBits API key */
|
|
28
|
+
apiKey: string;
|
|
29
|
+
/** Website slug (e.g. "my-landing" → my-landing.easybits.cloud) */
|
|
30
|
+
slug: string;
|
|
31
|
+
/** The sections to deploy */
|
|
32
|
+
sections: Section3[];
|
|
33
|
+
/** Theme ID */
|
|
34
|
+
theme?: string;
|
|
35
|
+
/** Custom colors (when theme is "custom") */
|
|
36
|
+
customColors?: CustomColors;
|
|
37
|
+
/** EasyBits API base URL (default: https://easybits.cloud) */
|
|
38
|
+
baseUrl?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Deploy a landing page to EasyBits hosting (slug.easybits.cloud).
|
|
43
|
+
* Uses the EasyBits API to create/update a website.
|
|
44
|
+
*/
|
|
45
|
+
export async function deployToEasyBits(options: DeployToEasyBitsOptions): Promise<string> {
|
|
46
|
+
const {
|
|
47
|
+
apiKey,
|
|
48
|
+
slug,
|
|
49
|
+
sections,
|
|
50
|
+
theme,
|
|
51
|
+
customColors,
|
|
52
|
+
baseUrl = "https://easybits.cloud",
|
|
53
|
+
} = options;
|
|
54
|
+
|
|
55
|
+
const html = buildDeployHtml(sections, theme, customColors);
|
|
56
|
+
|
|
57
|
+
const res = await fetch(`${baseUrl}/api/v2/websites`, {
|
|
58
|
+
method: "POST",
|
|
59
|
+
headers: {
|
|
60
|
+
"Content-Type": "application/json",
|
|
61
|
+
Authorization: `Bearer ${apiKey}`,
|
|
62
|
+
},
|
|
63
|
+
body: JSON.stringify({ slug, html }),
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
if (!res.ok) {
|
|
67
|
+
const error = await res.json().catch(() => ({ error: "Deploy failed" }));
|
|
68
|
+
throw new Error(error.error || "Deploy failed");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const data = await res.json();
|
|
72
|
+
return data.url || `https://${slug}.easybits.cloud`;
|
|
73
|
+
}
|
package/src/generate.ts
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { streamText } from "ai";
|
|
2
|
+
import { createAnthropic } from "@ai-sdk/anthropic";
|
|
3
|
+
import { nanoid } from "nanoid";
|
|
4
|
+
import { findImageSlots } from "./images/enrichImages";
|
|
5
|
+
import { searchImage } from "./images/pexels";
|
|
6
|
+
import type { Section3 } from "./types";
|
|
7
|
+
|
|
8
|
+
export const SYSTEM_PROMPT = `You are a world-class web designer who creates AWARD-WINNING landing pages. Your designs win Awwwards, FWA, and CSS Design Awards. You think in terms of visual hierarchy, whitespace, and emotional impact.
|
|
9
|
+
|
|
10
|
+
RULES:
|
|
11
|
+
- Each section is a complete <section> tag with Tailwind CSS classes
|
|
12
|
+
- Use Tailwind CDN classes ONLY (no custom CSS, no @apply, no @import, no @tailwind directives)
|
|
13
|
+
- NO JavaScript, only HTML+Tailwind
|
|
14
|
+
- Each section must be independent and self-contained
|
|
15
|
+
- Responsive: mobile-first with sm/md/lg/xl breakpoints
|
|
16
|
+
- All text content in Spanish unless the prompt specifies otherwise
|
|
17
|
+
- Use real-looking content (not Lorem ipsum) — make it specific to the prompt
|
|
18
|
+
|
|
19
|
+
IMAGES — CRITICAL:
|
|
20
|
+
- Use <img data-image-query="english search query" alt="description" class="..."/>
|
|
21
|
+
- NEVER include a src attribute — the system auto-replaces data-image-query with a real image URL
|
|
22
|
+
- For avatar-like elements, use colored divs with initials instead of img tags (e.g. <div class="w-10 h-10 rounded-full bg-primary flex items-center justify-center text-on-primary font-bold">JD</div>)
|
|
23
|
+
|
|
24
|
+
COLOR SYSTEM — CRITICAL (READ CAREFULLY):
|
|
25
|
+
- Use semantic color classes: bg-primary, text-primary, bg-primary-light, bg-primary-dark, text-on-primary, bg-surface, bg-surface-alt, text-on-surface, text-on-surface-muted, bg-secondary, text-secondary, bg-accent, text-accent
|
|
26
|
+
- NEVER use hardcoded Tailwind color classes: NO bg-gray-*, bg-black, bg-white, bg-indigo-*, bg-blue-*, bg-purple-*, text-gray-*, text-black, text-white, etc.
|
|
27
|
+
- The ONLY exception: border-gray-200 or border-gray-700 for subtle dividers.
|
|
28
|
+
- ALL backgrounds MUST use: bg-primary, bg-primary-dark, bg-surface, bg-surface-alt
|
|
29
|
+
- ALL text MUST use: text-on-surface, text-on-surface-muted, text-on-primary, text-primary, text-accent
|
|
30
|
+
- CONTRAST RULE: on bg-primary or bg-primary-dark → use text-on-primary. On bg-surface or bg-surface-alt → use text-on-surface or text-on-surface-muted. NEVER put text-on-surface on bg-primary or vice versa.
|
|
31
|
+
- For gradients: from-primary to-primary-dark, from-surface to-surface-alt
|
|
32
|
+
- For hover: hover:bg-primary-dark, hover:bg-primary-light
|
|
33
|
+
|
|
34
|
+
DESIGN PHILOSOPHY — what separates good from GREAT:
|
|
35
|
+
- WHITESPACE is your best friend. Generous padding (py-24, py-32, px-8). Let elements breathe.
|
|
36
|
+
- CONTRAST: mix dark sections with light ones. Alternate bg-primary and bg-surface sections.
|
|
37
|
+
- TYPOGRAPHY: use extreme size differences for hierarchy (text-7xl headline next to text-sm label)
|
|
38
|
+
- DEPTH: overlapping elements, negative margins (-mt-12), z-index layering, shadows
|
|
39
|
+
- ASYMMETRY: avoid centering everything. Use grid-cols-5 with col-span-3 + col-span-2. Offset elements.
|
|
40
|
+
- TEXTURE: use subtle patterns, gradients, border treatments, rounded-3xl mixed with sharp edges
|
|
41
|
+
- Each section should have a COMPLETELY DIFFERENT layout from the others
|
|
42
|
+
|
|
43
|
+
HERO SECTION — your masterpiece:
|
|
44
|
+
- Bento-grid or asymmetric layout, NOT a generic centered hero
|
|
45
|
+
- Large headline block + smaller stat/metric cards in a grid
|
|
46
|
+
- Real social proof: "2,847+ users", avatar stack (colored divs with initials), star ratings
|
|
47
|
+
- Bold oversized headline (text-6xl/7xl font-black leading-none)
|
|
48
|
+
- Tag/label above headline (uppercase, tracking-wider, text-xs)
|
|
49
|
+
- 2 CTAs: primary (large, with → arrow) + secondary (ghost/outlined)
|
|
50
|
+
- Real image via data-image-query
|
|
51
|
+
- Min height: min-h-[90vh] with generous padding
|
|
52
|
+
|
|
53
|
+
TAILWIND v3 NOTES:
|
|
54
|
+
- Standard Tailwind v3 classes (shadow-sm, shadow-md, rounded-md, etc.)
|
|
55
|
+
- Borders: border + border-gray-200 for visible borders`;
|
|
56
|
+
|
|
57
|
+
export const PROMPT_SUFFIX = `
|
|
58
|
+
|
|
59
|
+
OUTPUT FORMAT: NDJSON — one JSON object per line, NO wrapper array, NO markdown fences.
|
|
60
|
+
Each line: {"label": "Short Label", "html": "<section>...</section>"}
|
|
61
|
+
|
|
62
|
+
Generate 7-9 sections. Always start with Hero and end with Footer.
|
|
63
|
+
IMPORTANT: Make each section VISUALLY UNIQUE — different layouts, different background colors, different grid structures.
|
|
64
|
+
Think like a premium design agency creating a $50K landing page.
|
|
65
|
+
NO generic Bootstrap layouts. Use creative grids, bento layouts, overlapping elements, asymmetric columns.`;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Extract complete JSON objects from accumulated text using brace-depth tracking.
|
|
69
|
+
*/
|
|
70
|
+
export function extractJsonObjects(text: string): [any[], string] {
|
|
71
|
+
const objects: any[] = [];
|
|
72
|
+
let remaining = text;
|
|
73
|
+
|
|
74
|
+
while (remaining.length > 0) {
|
|
75
|
+
remaining = remaining.trimStart();
|
|
76
|
+
if (!remaining.startsWith("{")) {
|
|
77
|
+
const nextBrace = remaining.indexOf("{");
|
|
78
|
+
if (nextBrace === -1) break;
|
|
79
|
+
remaining = remaining.slice(nextBrace);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
let depth = 0;
|
|
84
|
+
let inString = false;
|
|
85
|
+
let escape = false;
|
|
86
|
+
let end = -1;
|
|
87
|
+
|
|
88
|
+
for (let i = 0; i < remaining.length; i++) {
|
|
89
|
+
const ch = remaining[i];
|
|
90
|
+
if (escape) { escape = false; continue; }
|
|
91
|
+
if (ch === "\\") { escape = true; continue; }
|
|
92
|
+
if (ch === '"') { inString = !inString; continue; }
|
|
93
|
+
if (inString) continue;
|
|
94
|
+
if (ch === "{") depth++;
|
|
95
|
+
if (ch === "}") { depth--; if (depth === 0) { end = i; break; } }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (end === -1) break;
|
|
99
|
+
|
|
100
|
+
const candidate = remaining.slice(0, end + 1);
|
|
101
|
+
remaining = remaining.slice(end + 1);
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
objects.push(JSON.parse(candidate));
|
|
105
|
+
} catch {
|
|
106
|
+
// malformed, skip
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return [objects, remaining];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface GenerateOptions {
|
|
114
|
+
/** Anthropic API key. Falls back to ANTHROPIC_API_KEY env var */
|
|
115
|
+
anthropicApiKey?: string;
|
|
116
|
+
/** Landing page description prompt */
|
|
117
|
+
prompt: string;
|
|
118
|
+
/** Reference image (base64 data URI) for vision-based generation */
|
|
119
|
+
referenceImage?: string;
|
|
120
|
+
/** Extra instructions appended to the prompt */
|
|
121
|
+
extraInstructions?: string;
|
|
122
|
+
/** Custom system prompt (overrides default SYSTEM_PROMPT) */
|
|
123
|
+
systemPrompt?: string;
|
|
124
|
+
/** Model ID (default: claude-sonnet-4-6) */
|
|
125
|
+
model?: string;
|
|
126
|
+
/** Pexels API key for image enrichment. Falls back to PEXELS_API_KEY env var */
|
|
127
|
+
pexelsApiKey?: string;
|
|
128
|
+
/** Called when a new section is parsed from the stream */
|
|
129
|
+
onSection?: (section: Section3) => void;
|
|
130
|
+
/** Called when a section's images are enriched */
|
|
131
|
+
onImageUpdate?: (sectionId: string, html: string) => void;
|
|
132
|
+
/** Called when generation is complete */
|
|
133
|
+
onDone?: (sections: Section3[]) => void;
|
|
134
|
+
/** Called on error */
|
|
135
|
+
onError?: (error: Error) => void;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Generate a landing page with streaming AI + image enrichment.
|
|
140
|
+
* Returns all generated sections when complete.
|
|
141
|
+
*/
|
|
142
|
+
export async function generateLanding(options: GenerateOptions): Promise<Section3[]> {
|
|
143
|
+
const {
|
|
144
|
+
anthropicApiKey,
|
|
145
|
+
prompt,
|
|
146
|
+
referenceImage,
|
|
147
|
+
extraInstructions,
|
|
148
|
+
systemPrompt = SYSTEM_PROMPT,
|
|
149
|
+
model: modelId = "claude-sonnet-4-6",
|
|
150
|
+
pexelsApiKey,
|
|
151
|
+
onSection,
|
|
152
|
+
onImageUpdate,
|
|
153
|
+
onDone,
|
|
154
|
+
onError,
|
|
155
|
+
} = options;
|
|
156
|
+
|
|
157
|
+
const anthropic = anthropicApiKey
|
|
158
|
+
? createAnthropic({ apiKey: anthropicApiKey })
|
|
159
|
+
: createAnthropic();
|
|
160
|
+
|
|
161
|
+
const model = anthropic(modelId);
|
|
162
|
+
|
|
163
|
+
// Build prompt content (supports multimodal with reference image)
|
|
164
|
+
const extra = extraInstructions ? `\nAdditional instructions: ${extraInstructions}` : "";
|
|
165
|
+
const content: any[] = [];
|
|
166
|
+
if (referenceImage) {
|
|
167
|
+
content.push({ type: "image", image: referenceImage });
|
|
168
|
+
content.push({
|
|
169
|
+
type: "text",
|
|
170
|
+
text: `Generate a landing page inspired by this reference image for: ${prompt}${extra}${PROMPT_SUFFIX}`,
|
|
171
|
+
});
|
|
172
|
+
} else {
|
|
173
|
+
content.push({
|
|
174
|
+
type: "text",
|
|
175
|
+
text: `Generate a landing page for: ${prompt}${extra}${PROMPT_SUFFIX}`,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const result = streamText({
|
|
180
|
+
model,
|
|
181
|
+
system: systemPrompt,
|
|
182
|
+
messages: [{ role: "user", content }],
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
const allSections: Section3[] = [];
|
|
186
|
+
const imagePromises: Promise<void>[] = [];
|
|
187
|
+
let sectionOrder = 0;
|
|
188
|
+
let buffer = "";
|
|
189
|
+
|
|
190
|
+
try {
|
|
191
|
+
for await (const chunk of result.textStream) {
|
|
192
|
+
buffer += chunk;
|
|
193
|
+
|
|
194
|
+
const [objects, remaining] = extractJsonObjects(buffer);
|
|
195
|
+
buffer = remaining;
|
|
196
|
+
|
|
197
|
+
for (const obj of objects) {
|
|
198
|
+
if (!obj.html || !obj.label) continue;
|
|
199
|
+
|
|
200
|
+
const section: Section3 = {
|
|
201
|
+
id: nanoid(8),
|
|
202
|
+
order: sectionOrder++,
|
|
203
|
+
html: obj.html,
|
|
204
|
+
label: obj.label,
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
allSections.push(section);
|
|
208
|
+
onSection?.(section);
|
|
209
|
+
|
|
210
|
+
// Enrich images with Pexels (non-blocking per section)
|
|
211
|
+
const slots = findImageSlots(section.html);
|
|
212
|
+
if (slots.length > 0) {
|
|
213
|
+
const sectionRef = section;
|
|
214
|
+
const slotsSnapshot = slots.map((s) => ({ ...s }));
|
|
215
|
+
imagePromises.push(
|
|
216
|
+
(async () => {
|
|
217
|
+
const results = await Promise.allSettled(
|
|
218
|
+
slotsSnapshot.map(async (slot) => {
|
|
219
|
+
const img = await searchImage(slot.query, pexelsApiKey).catch(() => null);
|
|
220
|
+
const url = img?.url || `https://placehold.co/800x500/1f2937/9ca3af?text=${encodeURIComponent(slot.query.slice(0, 30))}`;
|
|
221
|
+
return { slot, url };
|
|
222
|
+
})
|
|
223
|
+
);
|
|
224
|
+
let html = sectionRef.html;
|
|
225
|
+
for (const r of results) {
|
|
226
|
+
if (r.status === "fulfilled" && r.value) {
|
|
227
|
+
const { slot, url } = r.value;
|
|
228
|
+
const replacement = slot.replaceStr.replace("{url}", url);
|
|
229
|
+
html = html.replaceAll(slot.searchStr, replacement);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
if (html !== sectionRef.html) {
|
|
233
|
+
sectionRef.html = html;
|
|
234
|
+
onImageUpdate?.(sectionRef.id, html);
|
|
235
|
+
}
|
|
236
|
+
})()
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Parse remaining buffer
|
|
243
|
+
if (buffer.trim()) {
|
|
244
|
+
let cleaned = buffer.trim();
|
|
245
|
+
if (cleaned.startsWith("```")) {
|
|
246
|
+
cleaned = cleaned
|
|
247
|
+
.replace(/^```(?:json)?\s*/, "")
|
|
248
|
+
.replace(/\s*```$/, "");
|
|
249
|
+
}
|
|
250
|
+
const [lastObjects] = extractJsonObjects(cleaned);
|
|
251
|
+
for (const obj of lastObjects) {
|
|
252
|
+
if (!obj.html || !obj.label) continue;
|
|
253
|
+
const section: Section3 = {
|
|
254
|
+
id: nanoid(8),
|
|
255
|
+
order: sectionOrder++,
|
|
256
|
+
html: obj.html,
|
|
257
|
+
label: obj.label,
|
|
258
|
+
};
|
|
259
|
+
allSections.push(section);
|
|
260
|
+
onSection?.(section);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Wait for image enrichment
|
|
265
|
+
await Promise.allSettled(imagePromises);
|
|
266
|
+
|
|
267
|
+
onDone?.(allSections);
|
|
268
|
+
return allSections;
|
|
269
|
+
} catch (err: any) {
|
|
270
|
+
const error = err instanceof Error ? err : new Error(err?.message || "Generation failed");
|
|
271
|
+
onError?.(error);
|
|
272
|
+
throw error;
|
|
273
|
+
}
|
|
274
|
+
}
|