@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.
@@ -0,0 +1,261 @@
1
+ /**
2
+ * JavaScript injected into the landing v3 iframe.
3
+ * Handles hover highlights, click selection, contentEditable text editing,
4
+ * postMessage communication with the parent editor,
5
+ * and incremental section injection from parent.
6
+ */
7
+ export function getIframeScript(): string {
8
+ return `
9
+ (function() {
10
+ let hoveredEl = null;
11
+ let selectedEl = null;
12
+ const OUTLINE_HOVER = '2px solid #3B82F6';
13
+ const OUTLINE_SELECTED = '2px solid #8B5CF6';
14
+
15
+ function getSectionId(el) {
16
+ let node = el;
17
+ while (node && node !== document.body) {
18
+ if (node.dataset && node.dataset.sectionId) {
19
+ return node.dataset.sectionId;
20
+ }
21
+ node = node.parentElement;
22
+ }
23
+ return null;
24
+ }
25
+
26
+ function getSectionElement(sectionId) {
27
+ return document.querySelector('[data-section-id="' + sectionId + '"]');
28
+ }
29
+
30
+ function getElementPath(el) {
31
+ const parts = [];
32
+ let node = el;
33
+ while (node && node !== document.body) {
34
+ let tag = node.tagName.toLowerCase();
35
+ if (node.id) { tag += '#' + node.id; }
36
+ const siblings = node.parentElement ? Array.from(node.parentElement.children).filter(function(c) { return c.tagName === node.tagName; }) : [];
37
+ if (siblings.length > 1) { tag += ':nth(' + siblings.indexOf(node) + ')'; }
38
+ parts.unshift(tag);
39
+ node = node.parentElement;
40
+ }
41
+ return parts.join(' > ');
42
+ }
43
+
44
+ function isTextElement(el) {
45
+ var textTags = ['H1','H2','H3','H4','H5','H6','P','SPAN','LI','A','BLOCKQUOTE','LABEL','TD','TH','FIGCAPTION','BUTTON'];
46
+ return textTags.indexOf(el.tagName) !== -1;
47
+ }
48
+
49
+ // Hover
50
+ document.addEventListener('mouseover', function(e) {
51
+ var el = e.target;
52
+ if (el === document.body || el === document.documentElement) return;
53
+ if (el === selectedEl) return;
54
+ if (hoveredEl && hoveredEl !== selectedEl) {
55
+ hoveredEl.style.outline = '';
56
+ hoveredEl.style.outlineOffset = '';
57
+ }
58
+ hoveredEl = el;
59
+ if (el !== selectedEl) {
60
+ el.style.outline = OUTLINE_HOVER;
61
+ el.style.outlineOffset = '-2px';
62
+ }
63
+ });
64
+
65
+ document.addEventListener('mouseout', function(e) {
66
+ if (hoveredEl && hoveredEl !== selectedEl) {
67
+ hoveredEl.style.outline = '';
68
+ hoveredEl.style.outlineOffset = '';
69
+ }
70
+ hoveredEl = null;
71
+ });
72
+
73
+ // Click — select element
74
+ document.addEventListener('click', function(e) {
75
+ e.preventDefault();
76
+ e.stopPropagation();
77
+ var el = e.target;
78
+
79
+ // Deselect previous
80
+ if (selectedEl) {
81
+ selectedEl.style.outline = '';
82
+ selectedEl.style.outlineOffset = '';
83
+ }
84
+
85
+ if (selectedEl === el) {
86
+ selectedEl = null;
87
+ window.parent.postMessage({ type: 'element-deselected' }, '*');
88
+ return;
89
+ }
90
+
91
+ selectedEl = el;
92
+
93
+ // Clear hover styles BEFORE capturing openTag (so it matches source HTML)
94
+ el.style.outline = '';
95
+ el.style.outlineOffset = '';
96
+ var openTag = el.outerHTML.substring(0, el.outerHTML.indexOf('>') + 1).substring(0, 120);
97
+
98
+ el.style.outline = OUTLINE_SELECTED;
99
+ el.style.outlineOffset = '-2px';
100
+
101
+ var rect = el.getBoundingClientRect();
102
+ var attrs = {};
103
+ if (el.tagName === 'IMG') {
104
+ attrs = { src: el.getAttribute('src') || '', alt: el.getAttribute('alt') || '' };
105
+ }
106
+ if (el.tagName === 'A') {
107
+ attrs = { href: el.getAttribute('href') || '', target: el.getAttribute('target') || '' };
108
+ }
109
+
110
+ window.parent.postMessage({
111
+ type: 'element-selected',
112
+ sectionId: getSectionId(el),
113
+ tagName: el.tagName,
114
+ rect: { top: rect.top, left: rect.left, width: rect.width, height: rect.height },
115
+ text: (el.textContent || '').substring(0, 200),
116
+ openTag: openTag,
117
+ elementPath: getElementPath(el),
118
+ isSectionRoot: el.dataset && el.dataset.sectionId ? true : false,
119
+ attrs: attrs,
120
+ }, '*');
121
+ }, true);
122
+
123
+ // Double-click — contentEditable for text
124
+ document.addEventListener('dblclick', function(e) {
125
+ e.preventDefault();
126
+ e.stopPropagation();
127
+ var el = e.target;
128
+ if (!isTextElement(el)) return;
129
+
130
+ el.contentEditable = 'true';
131
+ el.focus();
132
+ el.style.outline = '2px dashed #F59E0B';
133
+ el.style.outlineOffset = '-2px';
134
+
135
+ function onBlur() {
136
+ el.contentEditable = 'false';
137
+ el.style.outline = '';
138
+ el.style.outlineOffset = '';
139
+ el.removeEventListener('blur', onBlur);
140
+ el.removeEventListener('keydown', onKeydown);
141
+
142
+ var sid = getSectionId(el);
143
+ var sectionEl = sid ? getSectionElement(sid) : null;
144
+ window.parent.postMessage({
145
+ type: 'text-edited',
146
+ sectionId: sid,
147
+ elementPath: getElementPath(el),
148
+ newText: el.innerHTML,
149
+ sectionHtml: sectionEl ? sectionEl.innerHTML : null,
150
+ }, '*');
151
+
152
+ selectedEl = null;
153
+ }
154
+
155
+ function onKeydown(ev) {
156
+ if (ev.key === 'Escape') {
157
+ el.blur();
158
+ }
159
+ }
160
+
161
+ el.addEventListener('blur', onBlur);
162
+ el.addEventListener('keydown', onKeydown);
163
+ }, true);
164
+
165
+ // Listen for messages FROM parent (incremental section injection)
166
+ window.addEventListener('message', function(e) {
167
+ var msg = e.data;
168
+ if (!msg || !msg.action) return;
169
+
170
+ if (msg.action === 'add-section') {
171
+ var wrapper = document.createElement('div');
172
+ wrapper.setAttribute('data-section-id', msg.id);
173
+ wrapper.innerHTML = msg.html;
174
+ wrapper.style.animation = 'fadeInUp 0.4s ease-out';
175
+ document.body.appendChild(wrapper);
176
+ wrapper.scrollIntoView({ behavior: 'smooth', block: 'end' });
177
+ }
178
+
179
+ if (msg.action === 'update-section') {
180
+ var el = getSectionElement(msg.id);
181
+ if (el) { el.innerHTML = msg.html; }
182
+ }
183
+
184
+ if (msg.action === 'remove-section') {
185
+ var el = getSectionElement(msg.id);
186
+ if (el) { el.remove(); }
187
+ }
188
+
189
+ if (msg.action === 'reorder-sections') {
190
+ // msg.order = [id1, id2, id3, ...]
191
+ var order = msg.order;
192
+ for (var i = 0; i < order.length; i++) {
193
+ var el = getSectionElement(order[i]);
194
+ if (el) { document.body.appendChild(el); }
195
+ }
196
+ }
197
+
198
+ if (msg.action === 'update-attribute') {
199
+ var sectionEl = getSectionElement(msg.sectionId);
200
+ if (sectionEl) {
201
+ var target = null;
202
+ if (msg.elementPath) {
203
+ // Find element by matching path
204
+ var allEls = sectionEl.querySelectorAll(msg.tagName || '*');
205
+ for (var i = 0; i < allEls.length; i++) {
206
+ if (getElementPath(allEls[i]) === msg.elementPath) {
207
+ target = allEls[i];
208
+ break;
209
+ }
210
+ }
211
+ }
212
+ if (target) {
213
+ target.setAttribute(msg.attr, msg.value);
214
+ window.parent.postMessage({
215
+ type: 'section-html-updated',
216
+ sectionId: msg.sectionId,
217
+ sectionHtml: sectionEl.innerHTML,
218
+ }, '*');
219
+ }
220
+ }
221
+ }
222
+
223
+ if (msg.action === 'set-theme') {
224
+ if (msg.theme && msg.theme !== 'default') {
225
+ document.documentElement.setAttribute('data-theme', msg.theme);
226
+ } else {
227
+ document.documentElement.removeAttribute('data-theme');
228
+ }
229
+ }
230
+
231
+ if (msg.action === 'set-custom-css') {
232
+ var customStyle = document.getElementById('custom-theme-css');
233
+ if (!customStyle) {
234
+ customStyle = document.createElement('style');
235
+ customStyle.id = 'custom-theme-css';
236
+ document.head.appendChild(customStyle);
237
+ }
238
+ customStyle.textContent = msg.css || '';
239
+ }
240
+
241
+ if (msg.action === 'scroll-to-section') {
242
+ var el = getSectionElement(msg.id);
243
+ if (el) { el.scrollIntoView({ behavior: 'smooth', block: 'start' }); }
244
+ }
245
+
246
+ if (msg.action === 'full-rewrite') {
247
+ // Fallback: rewrite everything
248
+ document.body.innerHTML = msg.html;
249
+ }
250
+ });
251
+
252
+ // Inject animation keyframe
253
+ var style = document.createElement('style');
254
+ style.textContent = '@keyframes fadeInUp { from { opacity:0; transform:translateY(20px); } to { opacity:1; transform:translateY(0); } }';
255
+ document.head.appendChild(style);
256
+
257
+ // Notify parent we're ready
258
+ window.parent.postMessage({ type: 'ready' }, '*');
259
+ })();
260
+ `;
261
+ }
@@ -0,0 +1,127 @@
1
+ import { searchImage } from "./pexels";
2
+
3
+ interface ImageMatch {
4
+ query: string;
5
+ searchStr: string;
6
+ replaceStr: string;
7
+ }
8
+
9
+ const FAKE_DOMAINS = [
10
+ "images.unsplash.com",
11
+ "unsplash.com",
12
+ "via.placeholder.com",
13
+ "placeholder.com",
14
+ "placehold.co",
15
+ "placehold.it",
16
+ "placekitten.com",
17
+ "picsum.photos",
18
+ "loremflickr.com",
19
+ "source.unsplash.com",
20
+ "dummyimage.com",
21
+ "fakeimg.pl",
22
+ "example.com",
23
+ "img.freepik.com",
24
+ "cdn.pixabay.com",
25
+ ];
26
+
27
+ /**
28
+ * Find all images in HTML that need Pexels enrichment.
29
+ * Two strategies:
30
+ * 1. data-image-query="..." — AI followed instructions
31
+ * 2. <img src="fake-url" — detect fake domains, use alt/class/nearby text as query
32
+ */
33
+ export function findImageSlots(html: string): ImageMatch[] {
34
+ const matches: ImageMatch[] = [];
35
+ const seen = new Set<string>();
36
+
37
+ // 1. data-image-query="..."
38
+ const diqRegex = /data-image-query="([^"]+)"/g;
39
+ let m: RegExpExecArray | null;
40
+ while ((m = diqRegex.exec(html)) !== null) {
41
+ const query = m[1];
42
+ if (seen.has(query)) continue;
43
+ seen.add(query);
44
+ matches.push({
45
+ query,
46
+ searchStr: `data-image-query="${query}"`,
47
+ replaceStr: `src="{url}" data-enriched="true"`,
48
+ });
49
+ }
50
+
51
+ // 2. <img with fake/non-existent src URLs
52
+ const imgRegex = /<img\s[^>]*src="(https?:\/\/[^"]+)"[^>]*>/gi;
53
+ while ((m = imgRegex.exec(html)) !== null) {
54
+ const fullTag = m[0];
55
+ const srcUrl = m[1];
56
+
57
+ if (fullTag.includes("data-enriched")) continue;
58
+ if (srcUrl.includes("pexels.com")) continue;
59
+ if (seen.has(srcUrl)) continue;
60
+
61
+ // Check if domain is fake
62
+ let isFake = false;
63
+ try {
64
+ const domain = new URL(srcUrl).hostname;
65
+ isFake = FAKE_DOMAINS.some((d) => domain.includes(d));
66
+ } catch {
67
+ isFake = true;
68
+ }
69
+ if (!isFake) continue;
70
+
71
+ // Extract query: try alt, then class context, then URL path words
72
+ const altMatch = fullTag.match(/alt="([^"]*?)"/);
73
+ let query = altMatch?.[1]?.trim() || "";
74
+
75
+ if (!query) {
76
+ // Try to extract meaningful words from the URL path
77
+ try {
78
+ const path = new URL(srcUrl).pathname;
79
+ const words = path
80
+ .replace(/[^a-zA-Z]/g, " ")
81
+ .split(/\s+/)
82
+ .filter((w) => w.length > 2)
83
+ .slice(0, 4)
84
+ .join(" ");
85
+ if (words.length > 3) query = words;
86
+ } catch { /* ignore */ }
87
+ }
88
+
89
+ if (!query) query = "professional website hero image";
90
+
91
+ seen.add(srcUrl);
92
+ matches.push({
93
+ query,
94
+ searchStr: `src="${srcUrl}"`,
95
+ replaceStr: `src="{url}" data-enriched="true"`,
96
+ });
97
+ }
98
+
99
+ return matches;
100
+ }
101
+
102
+ /**
103
+ * Enrich all images in an HTML string with Pexels photos.
104
+ */
105
+ export async function enrichImages(html: string, pexelsApiKey?: string): Promise<string> {
106
+ const slots = findImageSlots(html);
107
+ if (slots.length === 0) return html;
108
+
109
+ let result = html;
110
+ const promises = slots.map(async (slot) => {
111
+ const img = await searchImage(slot.query, pexelsApiKey).catch(() => null);
112
+ const url = img?.url || `https://placehold.co/800x500/1f2937/9ca3af?text=${encodeURIComponent(slot.query.slice(0, 30))}`;
113
+ const replacement = slot.replaceStr.replace("{url}", url);
114
+ result = result.replaceAll(slot.searchStr, replacement);
115
+ });
116
+
117
+ await Promise.allSettled(promises);
118
+
119
+ // Catch any remaining <img> tags without src (AI didn't follow instructions)
120
+ result = result.replace(/<img\s(?![^>]*\bsrc=)([^>]*?)>/gi, (_match, attrs) => {
121
+ const altMatch = attrs.match(/alt="([^"]*?)"/);
122
+ const query = altMatch?.[1] || "professional image";
123
+ return `<img src="https://placehold.co/800x500/1f2937/9ca3af?text=${encodeURIComponent(query.slice(0, 30))}" ${attrs}>`;
124
+ });
125
+
126
+ return result;
127
+ }
@@ -0,0 +1,2 @@
1
+ export { searchImage, type PexelsResult } from "./pexels";
2
+ export { enrichImages, findImageSlots } from "./enrichImages";
@@ -0,0 +1,27 @@
1
+ export interface PexelsResult {
2
+ url: string;
3
+ photographer: string;
4
+ alt: string;
5
+ }
6
+
7
+ export async function searchImage(query: string, apiKey?: string): Promise<PexelsResult | null> {
8
+ const key = apiKey || process.env.PEXELS_API_KEY;
9
+ if (!key) return null;
10
+ try {
11
+ const res = await fetch(
12
+ `https://api.pexels.com/v1/search?query=${encodeURIComponent(query)}&per_page=1&orientation=landscape`,
13
+ { headers: { Authorization: key } }
14
+ );
15
+ if (!res.ok) return null;
16
+ const data = await res.json();
17
+ const photo = data.photos?.[0];
18
+ if (!photo) return null;
19
+ return {
20
+ url: photo.src.large,
21
+ photographer: photo.photographer,
22
+ alt: photo.alt || query,
23
+ };
24
+ } catch {
25
+ return null;
26
+ }
27
+ }
package/src/index.ts ADDED
@@ -0,0 +1,57 @@
1
+ // Types
2
+ export type { Section3, IframeMessage } from "./types";
3
+ export type { LandingTheme, CustomColors } from "./themes";
4
+
5
+ // Themes
6
+ export {
7
+ LANDING_THEMES,
8
+ buildCustomTheme,
9
+ buildCustomThemeCss,
10
+ buildThemeCss,
11
+ buildSingleThemeCss,
12
+ } from "./themes";
13
+
14
+ // HTML builders
15
+ export { buildPreviewHtml, buildDeployHtml } from "./buildHtml";
16
+ export { getIframeScript } from "./iframeScript";
17
+
18
+ // Generation
19
+ export {
20
+ generateLanding,
21
+ extractJsonObjects,
22
+ SYSTEM_PROMPT,
23
+ PROMPT_SUFFIX,
24
+ type GenerateOptions,
25
+ } from "./generate";
26
+
27
+ // Refinement
28
+ export {
29
+ refineLanding,
30
+ REFINE_SYSTEM,
31
+ type RefineOptions,
32
+ } from "./refine";
33
+
34
+ // Deploy
35
+ export {
36
+ deployToS3,
37
+ deployToEasyBits,
38
+ type DeployToS3Options,
39
+ type DeployToEasyBitsOptions,
40
+ } from "./deploy";
41
+
42
+ // Images
43
+ export {
44
+ searchImage,
45
+ enrichImages,
46
+ findImageSlots,
47
+ type PexelsResult,
48
+ } from "./images/index";
49
+
50
+ // Components (re-exported for convenience)
51
+ export {
52
+ Canvas,
53
+ type CanvasHandle,
54
+ SectionList,
55
+ FloatingToolbar,
56
+ CodeEditor,
57
+ } from "./components/index";
package/src/refine.ts ADDED
@@ -0,0 +1,115 @@
1
+ import { streamText } from "ai";
2
+ import { createAnthropic } from "@ai-sdk/anthropic";
3
+ import { enrichImages } from "./images/enrichImages";
4
+
5
+ export const REFINE_SYSTEM = `You are an expert HTML/Tailwind CSS developer. You receive the current HTML of a landing page section and a user instruction.
6
+
7
+ RULES:
8
+ - Return ONLY the modified HTML — no full page, no <html>/<head>/<body> tags
9
+ - Use Tailwind CSS classes (CDN loaded)
10
+ - You may use inline styles for specific adjustments
11
+ - Images: use data-image-query="english search query" for new images
12
+ - Keep all text in its original language unless asked to translate
13
+ - Be creative — don't just make minimal changes, improve the design
14
+ - Return raw HTML only — no markdown fences, no explanations
15
+
16
+ COLOR SYSTEM — CRITICAL:
17
+ - 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
18
+ - NEVER use hardcoded colors: NO bg-gray-*, bg-black, bg-white, text-gray-*, text-black, text-white, etc.
19
+ - The ONLY exception: border-gray-200 or border-gray-700 for subtle dividers.
20
+ - CONTRAST RULE: on bg-primary/bg-primary-dark → text-on-primary. On bg-surface/bg-surface-alt → text-on-surface/text-on-surface-muted. Never mismatch.
21
+
22
+ TAILWIND v3 NOTES:
23
+ - Standard Tailwind v3 classes (shadow-sm, shadow-md, rounded-md, etc.)
24
+ - Borders: border + border-gray-200 for visible borders`;
25
+
26
+ export interface RefineOptions {
27
+ /** Anthropic API key. Falls back to ANTHROPIC_API_KEY env var */
28
+ anthropicApiKey?: string;
29
+ /** Current HTML of the section being refined */
30
+ currentHtml: string;
31
+ /** User instruction for refinement */
32
+ instruction: string;
33
+ /** Reference image (base64 data URI) for vision-based refinement */
34
+ referenceImage?: string;
35
+ /** Custom system prompt (overrides default REFINE_SYSTEM) */
36
+ systemPrompt?: string;
37
+ /** Model ID (default: claude-haiku-4-5-20251001, claude-sonnet-4-6 when referenceImage is provided) */
38
+ model?: string;
39
+ /** Pexels API key for image enrichment. Falls back to PEXELS_API_KEY env var */
40
+ pexelsApiKey?: string;
41
+ /** Called with accumulated HTML as it streams */
42
+ onChunk?: (html: string) => void;
43
+ /** Called when refinement is complete with final enriched HTML */
44
+ onDone?: (html: string) => void;
45
+ /** Called on error */
46
+ onError?: (error: Error) => void;
47
+ }
48
+
49
+ /**
50
+ * Refine a landing page section with streaming AI.
51
+ * Returns the final enriched HTML.
52
+ */
53
+ export async function refineLanding(options: RefineOptions): Promise<string> {
54
+ const {
55
+ anthropicApiKey,
56
+ currentHtml,
57
+ instruction,
58
+ referenceImage,
59
+ systemPrompt = REFINE_SYSTEM,
60
+ model: modelId,
61
+ pexelsApiKey,
62
+ onChunk,
63
+ onDone,
64
+ onError,
65
+ } = options;
66
+
67
+ const anthropic = anthropicApiKey
68
+ ? createAnthropic({ apiKey: anthropicApiKey })
69
+ : createAnthropic();
70
+
71
+ // Use Haiku for speed, Sonnet for vision
72
+ const defaultModel = referenceImage ? "claude-sonnet-4-6" : "claude-haiku-4-5-20251001";
73
+ const model = anthropic(modelId || defaultModel);
74
+
75
+ // Build content (supports multimodal with reference image)
76
+ const content: any[] = [];
77
+ if (referenceImage) {
78
+ content.push({ type: "image", image: referenceImage });
79
+ }
80
+ content.push({
81
+ type: "text",
82
+ text: `Current HTML:\n${currentHtml}\n\nInstruction: ${instruction}\n\nReturn the updated HTML.`,
83
+ });
84
+
85
+ const result = streamText({
86
+ model,
87
+ system: systemPrompt,
88
+ messages: [{ role: "user", content }],
89
+ });
90
+
91
+ try {
92
+ let accumulated = "";
93
+
94
+ for await (const chunk of result.textStream) {
95
+ accumulated += chunk;
96
+ onChunk?.(accumulated);
97
+ }
98
+
99
+ // Clean up markdown fences if present
100
+ let html = accumulated.trim();
101
+ if (html.startsWith("```")) {
102
+ html = html.replace(/^```(?:html|xml)?\s*/, "").replace(/\s*```$/, "");
103
+ }
104
+
105
+ // Enrich images
106
+ html = await enrichImages(html, pexelsApiKey);
107
+
108
+ onDone?.(html);
109
+ return html;
110
+ } catch (err: any) {
111
+ const error = err instanceof Error ? err : new Error(err?.message || "Refine failed");
112
+ onError?.(error);
113
+ throw error;
114
+ }
115
+ }