@mahe_pkm/buzl-html-editor 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 +5 -0
- package/README.md +113 -0
- package/bin/buzl-editor.js +102 -0
- package/bin/buzl-site.js +77 -0
- package/dist/imageOptimizer.js +68 -0
- package/dist/imageService.js +242 -0
- package/dist/public/index.html +2005 -0
- package/dist/server.js +1045 -0
- package/dist/site-server.js +318 -0
- package/lib/cli.js +267 -0
- package/package.json +52 -0
- package/templates/config.example.json +6 -0
- package/templates/website_context.example.json +7 -0
package/dist/server.js
ADDED
|
@@ -0,0 +1,1045 @@
|
|
|
1
|
+
require('dotenv').config({ path: process.env.BUZL_ENV_FILE || require('path').join(process.cwd(), '.env') });
|
|
2
|
+
|
|
3
|
+
const express = require('express');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const crypto = require('crypto');
|
|
7
|
+
const cheerio = require('cheerio');
|
|
8
|
+
const multer = require('multer');
|
|
9
|
+
const Anthropic = require('@anthropic-ai/sdk');
|
|
10
|
+
const { compressImage } = require('./imageOptimizer');
|
|
11
|
+
|
|
12
|
+
const app = express();
|
|
13
|
+
const PORT = process.env.PORT || 4000;
|
|
14
|
+
const HOST = process.env.HOST || '127.0.0.1';
|
|
15
|
+
|
|
16
|
+
// Log all incoming requests to help debug asset loading issues
|
|
17
|
+
app.use((req, res, next) => {
|
|
18
|
+
console.log(`[${new Date().toISOString().split('T')[1].slice(0, 8)}] 📥 ${req.method} ${req.url}`);
|
|
19
|
+
next();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
// ─── PATHS ──────────────────────────────────────────────────────────────────
|
|
23
|
+
// Package code and client website files live in separate locations.
|
|
24
|
+
const ADMIN_DIR = path.join(__dirname, 'public');
|
|
25
|
+
const SITE_ROOT = path.resolve(process.env.BUZL_SITE_ROOT || process.cwd());
|
|
26
|
+
|
|
27
|
+
// ─── EVENT HANDLER PRESERVATION ─────────────────────────────────────────────
|
|
28
|
+
// GrapeJS strips inline event handlers on getHtml(). We convert them to
|
|
29
|
+
// data-ht-* attributes on load and back on save.
|
|
30
|
+
const EVENT_ATTRS = ['onclick', 'onsubmit', 'onchange', 'oninput', 'onkeydown', 'onkeyup', 'onmouseover', 'onmouseout', 'onfocus', 'onblur', 'onscroll'];
|
|
31
|
+
|
|
32
|
+
// Increase payload limit for saving full HTML pages (GrapeJS can produce
|
|
33
|
+
// very large payloads when pages contain embedded base64 images/assets)
|
|
34
|
+
app.use(express.json({ limit: '50mb' }));
|
|
35
|
+
app.use(express.urlencoded({ extended: true, limit: '50mb' }));
|
|
36
|
+
|
|
37
|
+
// ─── ADMIN ROUTES (must come before static serving) ─────────────────────────
|
|
38
|
+
|
|
39
|
+
// Serve only the editor entry page from the admin source directory. Generated
|
|
40
|
+
// images have their own explicit route below; server source, package files and
|
|
41
|
+
// local configuration must never be downloadable through /admin.
|
|
42
|
+
app.get(['/admin', '/admin/', '/admin/index.html'], (req, res) => {
|
|
43
|
+
res.sendFile(path.join(ADMIN_DIR, 'index.html'));
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// Allow only the two explicit public asset namespaces under /admin. Without
|
|
47
|
+
// this guard, the final website static route could expose admin/server.js and
|
|
48
|
+
// other source files because the admin directory sits inside the site root.
|
|
49
|
+
app.use('/admin', (req, res, next) => {
|
|
50
|
+
if (req.path.startsWith('/assets/') || req.path.startsWith('/generated/')) {
|
|
51
|
+
return next();
|
|
52
|
+
}
|
|
53
|
+
return res.status(404).send('Not Found');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// Resolve public site assets when the editor canvas requests them through
|
|
57
|
+
// the admin namespace.
|
|
58
|
+
app.use('/admin/assets', express.static(path.join(SITE_ROOT, 'assets')));
|
|
59
|
+
|
|
60
|
+
// ─── IMAGE UPLOAD (multer) ─────────────────────────────────────────────────
|
|
61
|
+
// Saves uploaded images to assets/images/ with their original filename,
|
|
62
|
+
// sanitised for the web and deduped with a short suffix if needed.
|
|
63
|
+
|
|
64
|
+
const IMAGES_DIR = path.join(SITE_ROOT, 'assets', 'images');
|
|
65
|
+
if (!fs.existsSync(IMAGES_DIR)) fs.mkdirSync(IMAGES_DIR, { recursive: true });
|
|
66
|
+
const ALLOWED_IMAGE_EXTENSIONS = new Set([
|
|
67
|
+
'.png', '.jpg', '.jpeg', '.webp', '.gif', '.avif', '.tif', '.tiff', '.svg',
|
|
68
|
+
]);
|
|
69
|
+
|
|
70
|
+
const upload = multer({
|
|
71
|
+
storage: multer.diskStorage({
|
|
72
|
+
destination: (req, file, cb) => cb(null, IMAGES_DIR),
|
|
73
|
+
filename: (req, file, cb) => {
|
|
74
|
+
// Sanitise: lowercase, replace spaces/special chars with hyphens, keep extension
|
|
75
|
+
const ext = path.extname(file.originalname).toLowerCase();
|
|
76
|
+
let base = path.basename(file.originalname, path.extname(file.originalname))
|
|
77
|
+
.toLowerCase()
|
|
78
|
+
.replace(/[^a-z0-9_-]/g, '-') // only safe chars
|
|
79
|
+
.replace(/-+/g, '-') // collapse multiple hyphens
|
|
80
|
+
.replace(/^-|-$/g, ''); // trim leading/trailing hyphens
|
|
81
|
+
|
|
82
|
+
if (!base) base = 'image';
|
|
83
|
+
|
|
84
|
+
let filename = base + ext;
|
|
85
|
+
// Dedupe: append short hash if file already exists with different content
|
|
86
|
+
if (fs.existsSync(path.join(IMAGES_DIR, filename))) {
|
|
87
|
+
const hash = crypto.createHash('md5').update(Date.now().toString()).digest('hex').slice(0, 6);
|
|
88
|
+
filename = `${base}-${hash}${ext}`;
|
|
89
|
+
}
|
|
90
|
+
cb(null, filename);
|
|
91
|
+
},
|
|
92
|
+
}),
|
|
93
|
+
limits: {
|
|
94
|
+
fileSize: 20 * 1024 * 1024, // 20 MB per file
|
|
95
|
+
files: 10,
|
|
96
|
+
},
|
|
97
|
+
fileFilter: (req, file, cb) => {
|
|
98
|
+
const ext = path.extname(file.originalname).toLowerCase();
|
|
99
|
+
if (file.mimetype.startsWith('image/') && ALLOWED_IMAGE_EXTENSIONS.has(ext)) {
|
|
100
|
+
cb(null, true);
|
|
101
|
+
} else {
|
|
102
|
+
cb(new Error('Only supported image files are allowed'));
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// GrapeJS posts files under the field name "files" (its default uploadName),
|
|
108
|
+
// but the exact field name can vary between GrapeJS versions. Using upload.any()
|
|
109
|
+
// ensures compatibility with any field name and both multer v1 and v2.
|
|
110
|
+
app.post('/api/upload', upload.any(), async (req, res) => {
|
|
111
|
+
try {
|
|
112
|
+
const urls = [];
|
|
113
|
+
|
|
114
|
+
for (const file of req.files || []) {
|
|
115
|
+
const ext = path.extname(file.filename).toLowerCase();
|
|
116
|
+
|
|
117
|
+
// Keep vector images as SVG; normalize all raster formats to AVIF.
|
|
118
|
+
if (ext === '.svg') {
|
|
119
|
+
const svg = fs.readFileSync(file.path, 'utf8');
|
|
120
|
+
const unsafeSvg = !/<svg(?:\s|>)/i.test(svg)
|
|
121
|
+
|| /<script(?:\s|>)/i.test(svg)
|
|
122
|
+
|| /<foreignObject(?:\s|>)/i.test(svg)
|
|
123
|
+
|| /\son[a-z]+\s*=/i.test(svg)
|
|
124
|
+
|| /javascript\s*:/i.test(svg);
|
|
125
|
+
if (unsafeSvg) {
|
|
126
|
+
fs.unlinkSync(file.path);
|
|
127
|
+
throw new Error('Unsafe SVG content was rejected');
|
|
128
|
+
}
|
|
129
|
+
urls.push(`/assets/images/${file.filename}`);
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const baseName = path.basename(file.filename, ext);
|
|
134
|
+
const contentHash = crypto.createHash('sha256')
|
|
135
|
+
.update(fs.readFileSync(file.path))
|
|
136
|
+
.digest('hex')
|
|
137
|
+
.slice(0, 8);
|
|
138
|
+
const outputStem = path.join(IMAGES_DIR, `${baseName}-${contentHash}`);
|
|
139
|
+
const existingOutput = `${outputStem}.avif`;
|
|
140
|
+
const optimized = fs.existsSync(existingOutput)
|
|
141
|
+
? { path: existingOutput, size: fs.statSync(existingOutput).size }
|
|
142
|
+
: await compressImage(file.path, outputStem);
|
|
143
|
+
|
|
144
|
+
if (file.path !== optimized.path && fs.existsSync(file.path)) {
|
|
145
|
+
fs.unlinkSync(file.path);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
urls.push(`/assets/images/${path.basename(optimized.path)}`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
console.log(` 📤 Uploaded and optimized ${urls.length} image(s): ${urls.join(', ')}`);
|
|
152
|
+
res.json({ data: urls });
|
|
153
|
+
} catch (err) {
|
|
154
|
+
for (const file of req.files || []) {
|
|
155
|
+
if (file.path && fs.existsSync(file.path)) {
|
|
156
|
+
try { fs.unlinkSync(file.path); } catch {}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
console.error(' ❌ Upload error:', err);
|
|
160
|
+
res.status(500).json({ error: 'Upload failed', details: err.message });
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// ─── API: AI Text Generation (chat completion via Anthropic) ────────────────
|
|
165
|
+
// Forwards chat-completion requests to Anthropic Claude API.
|
|
166
|
+
// Model is configurable via ANTHROPIC_MODEL env var.
|
|
167
|
+
|
|
168
|
+
const AI_MODEL = process.env.ANTHROPIC_MODEL || 'claude-sonnet-4-6';
|
|
169
|
+
const AI_MAX_TOKENS = parseInt(process.env.ANTHROPIC_MAX_TOKENS || '1024', 10);
|
|
170
|
+
|
|
171
|
+
// Load website context from JSON (cached at startup)
|
|
172
|
+
const WEBSITE_CONTEXT_PATH = process.env.BUZL_CONTEXT_FILE || path.join(SITE_ROOT, 'website_context.json');
|
|
173
|
+
let siteConfig = {};
|
|
174
|
+
try {
|
|
175
|
+
siteConfig = JSON.parse(fs.readFileSync(WEBSITE_CONTEXT_PATH, 'utf8'));
|
|
176
|
+
} catch (err) {
|
|
177
|
+
console.warn(' ⚠️ Could not load website_context.json:', err.message);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Generate system prompt from site config
|
|
181
|
+
function buildSystemPrompt() {
|
|
182
|
+
const { businessCategory, purpose, name, address, tone_and_style } = siteConfig;
|
|
183
|
+
if (!businessCategory) return '';
|
|
184
|
+
return `You are a copywriting assistant for a ${businessCategory} ${purpose || 'website'} (${name || ''}, ${address || ''}). Generate concise, compelling ${purpose || 'website'} text. Return ONLY the text content — no markdown formatting, no quotes, no explanation. Match the tone and style of ${tone_and_style || 'professional copywriting'}.`;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
app.post('/api/ai/generate-text', async (req, res) => {
|
|
188
|
+
const apiKey = process.env.ANTHROPIC_API_KEY;
|
|
189
|
+
if (!apiKey || apiKey === 'your-anthropic-api-key-here') {
|
|
190
|
+
return res.status(500).json({
|
|
191
|
+
error: 'ANTHROPIC_API_KEY not configured',
|
|
192
|
+
details: 'Please set a valid ANTHROPIC_API_KEY in .env in the website root',
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const { messages, model, max_tokens, generation_context } = req.body;
|
|
197
|
+
|
|
198
|
+
if (!messages || !Array.isArray(messages) || messages.length === 0) {
|
|
199
|
+
return res.status(400).json({
|
|
200
|
+
error: 'Missing or invalid "messages" array in request body',
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
try {
|
|
205
|
+
const client = new Anthropic({ apiKey });
|
|
206
|
+
|
|
207
|
+
// ── Build the final messages array ──────────────────────────────────
|
|
208
|
+
// Order: [0] website context (cached), [1] generation_context, [2..] user messages
|
|
209
|
+
// Anthropic requires alternating user/assistant roles, so we insert
|
|
210
|
+
// assistant placeholders between consecutive user messages.
|
|
211
|
+
|
|
212
|
+
const finalMessages = [];
|
|
213
|
+
|
|
214
|
+
// [0] Website context with prompt caching
|
|
215
|
+
if (siteConfig.context) {
|
|
216
|
+
finalMessages.push({
|
|
217
|
+
role: 'user',
|
|
218
|
+
content: [
|
|
219
|
+
{
|
|
220
|
+
type: 'text',
|
|
221
|
+
text: `context:\n${siteConfig.context}`,
|
|
222
|
+
cache_control: { type: 'ephemeral' },
|
|
223
|
+
},
|
|
224
|
+
],
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// [1] generation_context from frontend (e.g. "Hero section title")
|
|
229
|
+
if (generation_context) {
|
|
230
|
+
// Need assistant placeholder to avoid consecutive user messages
|
|
231
|
+
finalMessages.push({ role: 'assistant', content: 'Understood. I have the website context.' });
|
|
232
|
+
finalMessages.push({
|
|
233
|
+
role: 'user',
|
|
234
|
+
content: `generation_context: ${generation_context}`,
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// [2..] User messages from the frontend
|
|
239
|
+
for (const msg of messages) {
|
|
240
|
+
// Insert assistant placeholder if last message is also user role
|
|
241
|
+
if (finalMessages.length > 0 && finalMessages[finalMessages.length - 1].role === msg.role) {
|
|
242
|
+
finalMessages.push({ role: 'assistant', content: 'Understood.' });
|
|
243
|
+
}
|
|
244
|
+
finalMessages.push(msg);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const params = {
|
|
248
|
+
model: model || AI_MODEL,
|
|
249
|
+
max_tokens: max_tokens || AI_MAX_TOKENS,
|
|
250
|
+
messages: finalMessages,
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
// System prompt generated from website_context.json
|
|
254
|
+
const systemPrompt = buildSystemPrompt();
|
|
255
|
+
if (systemPrompt) {
|
|
256
|
+
params.system = systemPrompt;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
console.log(` 🤖 AI generate-text → model: ${params.model}, messages: ${finalMessages.length}`);
|
|
260
|
+
console.log(` 📋 AI payload:`, JSON.stringify(params, null, 2));
|
|
261
|
+
|
|
262
|
+
const response = await client.messages.create(params);
|
|
263
|
+
|
|
264
|
+
// Extract text content from the response
|
|
265
|
+
const textContent = response.content
|
|
266
|
+
.filter(block => block.type === 'text')
|
|
267
|
+
.map(block => block.text)
|
|
268
|
+
.join('\n');
|
|
269
|
+
|
|
270
|
+
res.json({
|
|
271
|
+
text: textContent,
|
|
272
|
+
model: response.model,
|
|
273
|
+
usage: response.usage,
|
|
274
|
+
stop_reason: response.stop_reason,
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
} catch (err) {
|
|
278
|
+
console.error(' ❌ AI generation error:', err.message);
|
|
279
|
+
const status = err.status || 500;
|
|
280
|
+
res.status(status).json({
|
|
281
|
+
error: 'AI text generation failed',
|
|
282
|
+
details: err.message,
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
// ─── API: AI Image Generation (via OpenRouter) ─────────────────────────────
|
|
288
|
+
|
|
289
|
+
const { createImageGenerator } = require('./imageService');
|
|
290
|
+
|
|
291
|
+
// Ensure generated images directory exists
|
|
292
|
+
const GENERATED_IMAGES_DIR = path.join(SITE_ROOT, '.buzl', 'generated', 'images');
|
|
293
|
+
if (!fs.existsSync(GENERATED_IMAGES_DIR)) {
|
|
294
|
+
fs.mkdirSync(GENERATED_IMAGES_DIR, { recursive: true });
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Lazy-init the image generator (only when first request comes in)
|
|
298
|
+
let imageGenerator = null;
|
|
299
|
+
function getImageGenerator() {
|
|
300
|
+
if (!imageGenerator) {
|
|
301
|
+
imageGenerator = createImageGenerator();
|
|
302
|
+
}
|
|
303
|
+
return imageGenerator;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Serve generated images statically
|
|
307
|
+
app.use('/admin/generated', express.static(path.join(SITE_ROOT, '.buzl', 'generated')));
|
|
308
|
+
|
|
309
|
+
// ─── API: AI Image Generation – Session-based Chat Threads ─────────────────
|
|
310
|
+
// In-memory session store: sessionId → { generationContext, thread[], abortController? }
|
|
311
|
+
const imageSessions = new Map();
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Create a new image generation session.
|
|
315
|
+
* POST /api/ai/image-session
|
|
316
|
+
* Body: { generation_context?: string }
|
|
317
|
+
* Returns: { sessionId }
|
|
318
|
+
*/
|
|
319
|
+
app.post('/api/ai/image-session', (req, res) => {
|
|
320
|
+
const sessionId = crypto.randomUUID();
|
|
321
|
+
const { generation_context } = req.body || {};
|
|
322
|
+
imageSessions.set(sessionId, {
|
|
323
|
+
generationContext: generation_context || '',
|
|
324
|
+
thread: [],
|
|
325
|
+
abortController: null,
|
|
326
|
+
});
|
|
327
|
+
console.log(` 🆕 Image session created: ${sessionId}`);
|
|
328
|
+
res.json({ sessionId });
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Get session thread.
|
|
333
|
+
* GET /api/ai/image-session/:id
|
|
334
|
+
* Returns: { sessionId, generationContext, thread }
|
|
335
|
+
*/
|
|
336
|
+
app.get('/api/ai/image-session/:id', (req, res) => {
|
|
337
|
+
const session = imageSessions.get(req.params.id);
|
|
338
|
+
if (!session) return res.status(404).json({ error: 'Session not found' });
|
|
339
|
+
res.json({
|
|
340
|
+
sessionId: req.params.id,
|
|
341
|
+
generationContext: session.generationContext,
|
|
342
|
+
thread: session.thread,
|
|
343
|
+
});
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Generate image within a session (appends to thread).
|
|
348
|
+
* POST /api/ai/image-session/:id/generate
|
|
349
|
+
* Body: { prompt }
|
|
350
|
+
* Returns: { url, model, thread }
|
|
351
|
+
*/
|
|
352
|
+
app.post('/api/ai/image-session/:id/generate', async (req, res) => {
|
|
353
|
+
const session = imageSessions.get(req.params.id);
|
|
354
|
+
if (!session) return res.status(404).json({ error: 'Session not found' });
|
|
355
|
+
|
|
356
|
+
const { prompt } = req.body;
|
|
357
|
+
if (!prompt || typeof prompt !== 'string' || !prompt.trim()) {
|
|
358
|
+
return res.status(400).json({ error: 'Missing or invalid "prompt" in request body' });
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Append user message to thread
|
|
362
|
+
session.thread.push({ role: 'user', text: prompt.trim(), timestamp: Date.now() });
|
|
363
|
+
|
|
364
|
+
try {
|
|
365
|
+
const gen = getImageGenerator();
|
|
366
|
+
|
|
367
|
+
// Create AbortController for this request
|
|
368
|
+
const abortController = new AbortController();
|
|
369
|
+
session.abortController = abortController;
|
|
370
|
+
|
|
371
|
+
// Build enriched prompt including full conversation history
|
|
372
|
+
const enrichedPrompt = buildSessionImagePrompt(prompt.trim(), session);
|
|
373
|
+
|
|
374
|
+
console.log(` 🖼️ AI session-generate → session: ${req.params.id}, prompt: ${prompt.substring(0, 100)}`);
|
|
375
|
+
|
|
376
|
+
const result = await gen.generate(enrichedPrompt, abortController.signal);
|
|
377
|
+
session.abortController = null;
|
|
378
|
+
|
|
379
|
+
// Save AI output as an optimized AVIF.
|
|
380
|
+
const hash = crypto.createHash('md5').update(result.imageBase64.slice(0, 2048)).digest('hex').slice(0, 10);
|
|
381
|
+
const baseName = `gen-${hash}-${Date.now()}`;
|
|
382
|
+
const outputStem = path.join(GENERATED_IMAGES_DIR, baseName);
|
|
383
|
+
const buffer = Buffer.from(result.imageBase64, 'base64');
|
|
384
|
+
const optimized = await compressImage(buffer, outputStem);
|
|
385
|
+
|
|
386
|
+
const imageUrl = `/admin/generated/images/${path.basename(optimized.path)}`;
|
|
387
|
+
console.log(` 💾 AI session image saved → ${imageUrl} (${(optimized.size / 1024).toFixed(1)} KB)`);
|
|
388
|
+
|
|
389
|
+
// Append assistant message to thread
|
|
390
|
+
session.thread.push({
|
|
391
|
+
role: 'assistant',
|
|
392
|
+
url: imageUrl,
|
|
393
|
+
model: result.model,
|
|
394
|
+
size: optimized.size,
|
|
395
|
+
timestamp: Date.now(),
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
res.json({
|
|
399
|
+
url: imageUrl,
|
|
400
|
+
model: result.model,
|
|
401
|
+
size: optimized.size,
|
|
402
|
+
thread: session.thread,
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
} catch (err) {
|
|
406
|
+
session.abortController = null;
|
|
407
|
+
if (err.message === 'Request cancelled') {
|
|
408
|
+
return res.status(499).json({ error: 'Image generation cancelled', thread: session.thread });
|
|
409
|
+
}
|
|
410
|
+
console.error(` ❌ AI session image error: ${err.message}`);
|
|
411
|
+
// Record error in thread so user can see it
|
|
412
|
+
session.thread.push({
|
|
413
|
+
role: 'error',
|
|
414
|
+
text: err.message,
|
|
415
|
+
timestamp: Date.now(),
|
|
416
|
+
});
|
|
417
|
+
res.status(500).json({
|
|
418
|
+
error: 'AI image generation failed',
|
|
419
|
+
details: err.message,
|
|
420
|
+
thread: session.thread,
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Cancel in-flight generation for a session.
|
|
427
|
+
* POST /api/ai/image-session/:id/cancel
|
|
428
|
+
*/
|
|
429
|
+
app.post('/api/ai/image-session/:id/cancel', (req, res) => {
|
|
430
|
+
const session = imageSessions.get(req.params.id);
|
|
431
|
+
if (session && session.abortController) {
|
|
432
|
+
session.abortController.abort();
|
|
433
|
+
session.abortController = null;
|
|
434
|
+
console.log(` 🛑 Image session cancelled: ${req.params.id}`);
|
|
435
|
+
}
|
|
436
|
+
res.json({ cancelled: true });
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Delete a session.
|
|
441
|
+
* DELETE /api/ai/image-session/:id
|
|
442
|
+
*/
|
|
443
|
+
app.delete('/api/ai/image-session/:id', (req, res) => {
|
|
444
|
+
imageSessions.delete(req.params.id);
|
|
445
|
+
res.json({ deleted: true });
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Build an enriched image prompt that includes the full conversation history.
|
|
450
|
+
* This gives the model context about what was previously requested and generated.
|
|
451
|
+
*/
|
|
452
|
+
// Standard negative prompts to avoid common generation artefacts
|
|
453
|
+
const NEGATIVE_PROMPT = [
|
|
454
|
+
'Do NOT generate multiple images, collages, split views, side-by-side comparisons, before/after, tiled images, or image grids.',
|
|
455
|
+
'Do NOT render in 3D, CGI, or unrealistic plastic-looking style.',
|
|
456
|
+
'Do NOT add text, watermarks, labels, borders, or frames to the image.',
|
|
457
|
+
'Generate exactly ONE single cohesive photographic image.',
|
|
458
|
+
].join(' ');
|
|
459
|
+
|
|
460
|
+
function buildSessionImagePrompt(currentPrompt, session) {
|
|
461
|
+
const parts = [];
|
|
462
|
+
|
|
463
|
+
// Site context
|
|
464
|
+
if (siteConfig.businessCategory || siteConfig.name) {
|
|
465
|
+
parts.push(`Context: This image is for a ${siteConfig.businessCategory || 'website'} called "${siteConfig.name || 'Website'}" based in ${siteConfig.city || ''}, ${siteConfig.country || ''}.`);
|
|
466
|
+
}
|
|
467
|
+
if (siteConfig.tone_and_style) {
|
|
468
|
+
parts.push(`Visual style should match: ${siteConfig.tone_and_style}.`);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// Generation context from the session (e.g. "Hero section background, 1920x600")
|
|
472
|
+
if (session.generationContext) {
|
|
473
|
+
parts.push(`Element context: ${session.generationContext}`);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// Extract aspect ratio from generation context if present
|
|
477
|
+
const arMatch = (session.generationContext || '').match(/Aspect ratio:\s*(\d+:\d+)/i);
|
|
478
|
+
if (arMatch) {
|
|
479
|
+
parts.push(`IMPORTANT: Generate the image in ${arMatch[1]} aspect ratio.`);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// Negative prompts (always included)
|
|
483
|
+
parts.push(NEGATIVE_PROMPT);
|
|
484
|
+
|
|
485
|
+
// Include prior conversation for context (skip the latest user message, we add it at the end)
|
|
486
|
+
const priorMessages = session.thread.slice(0, -1); // exclude the just-added user message
|
|
487
|
+
if (priorMessages.length > 0) {
|
|
488
|
+
const historyLines = priorMessages.map(m => {
|
|
489
|
+
if (m.role === 'user') return `User requested: "${m.text}"`;
|
|
490
|
+
if (m.role === 'assistant') return `[Image was generated successfully]`;
|
|
491
|
+
if (m.role === 'error') return `[Generation failed: ${m.text}]`;
|
|
492
|
+
return '';
|
|
493
|
+
}).filter(Boolean);
|
|
494
|
+
parts.push(`Previous conversation:\n${historyLines.join('\n')}`);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// Current request
|
|
498
|
+
parts.push(currentPrompt);
|
|
499
|
+
|
|
500
|
+
return parts.join('\n\n');
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// ─── API: Site config (public fields only) ─────────────────────────────────
|
|
504
|
+
|
|
505
|
+
app.get('/api/site-config', (req, res) => {
|
|
506
|
+
const { name, city, country, businessCategory, purpose } = siteConfig;
|
|
507
|
+
res.json({ name, city, country, businessCategory, purpose });
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
// ─── API: List editable HTML pages ──────────────────────────────────────────
|
|
511
|
+
|
|
512
|
+
app.get('/api/pages', (req, res) => {
|
|
513
|
+
try {
|
|
514
|
+
const htmlFiles = sortEditablePages(findHtmlFiles(SITE_ROOT));
|
|
515
|
+
res.json({ pages: htmlFiles });
|
|
516
|
+
} catch (err) {
|
|
517
|
+
res.status(500).json({ error: 'Failed to list pages', details: err.message });
|
|
518
|
+
}
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
// ─── API: Load a page for editing ───────────────────────────────────────────
|
|
522
|
+
|
|
523
|
+
app.get('/api/pages/*', (req, res) => {
|
|
524
|
+
try {
|
|
525
|
+
const filename = decodeURIComponent(req.params[0]);
|
|
526
|
+
const filePath = resolveEditableHtmlPath(filename);
|
|
527
|
+
if (!filePath) {
|
|
528
|
+
return res.status(403).json({ error: 'Access denied' });
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// Security: prevent directory traversal
|
|
532
|
+
if (!isInsidePath(SITE_ROOT, filePath) || !filePath.endsWith('.html')) {
|
|
533
|
+
return res.status(403).json({ error: 'Access denied' });
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// Don't allow editing admin files
|
|
537
|
+
if (isInsidePath(ADMIN_DIR, filePath)) {
|
|
538
|
+
return res.status(403).json({ error: 'Cannot edit admin files' });
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
if (isExcludedEditablePath(filename)) {
|
|
542
|
+
return res.status(403).json({ error: 'Cannot edit duplicate or backup pages' });
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
if (!fs.existsSync(filePath)) {
|
|
546
|
+
return res.status(404).json({ error: 'File not found' });
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
const html = fs.readFileSync(filePath, 'utf8');
|
|
550
|
+
const $ = cheerio.load(html, { decodeEntities: false });
|
|
551
|
+
|
|
552
|
+
// Extract head content (raw) for preservation on save
|
|
553
|
+
const headContent = $('head').html() || '';
|
|
554
|
+
|
|
555
|
+
// Parse head into structured resources for GrapeJS canvas
|
|
556
|
+
const cssLinks = []; // external stylesheet URLs
|
|
557
|
+
const scriptSrcs = []; // external script URLs
|
|
558
|
+
const inlineStyles = []; // inline <style> blocks
|
|
559
|
+
const inlineScripts = []; // inline <script> blocks (e.g. Tailwind config)
|
|
560
|
+
|
|
561
|
+
$('head link[rel="stylesheet"]').each((_, el) => {
|
|
562
|
+
const href = $(el).attr('href');
|
|
563
|
+
if (href) cssLinks.push(href);
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
$('head script').each((_, el) => {
|
|
567
|
+
const src = $(el).attr('src');
|
|
568
|
+
const type = $(el).attr('type') || '';
|
|
569
|
+
// Skip schema.org / ld+json
|
|
570
|
+
if (type === 'application/ld+json') return;
|
|
571
|
+
if (src) {
|
|
572
|
+
scriptSrcs.push(src);
|
|
573
|
+
} else {
|
|
574
|
+
const text = $(el).html() || '';
|
|
575
|
+
// Skip GTM / analytics snippets
|
|
576
|
+
if (text.includes('googletagmanager') || text.includes('gtag(')) return;
|
|
577
|
+
if (text.trim()) inlineScripts.push(text);
|
|
578
|
+
}
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
$('head style').each((_, el) => {
|
|
582
|
+
const id = $(el).attr('id') || '';
|
|
583
|
+
// Skip GrapeJS injected styles (handled by GrapeJS itself)
|
|
584
|
+
if (id === 'gjs-editor-styles') return;
|
|
585
|
+
const text = $(el).html() || '';
|
|
586
|
+
if (text.trim()) inlineStyles.push(text);
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
// Tag <button> elements that are NOT inside a <form> so GrapeJS treats
|
|
590
|
+
// them as regular components instead of the forms-plugin "button" type.
|
|
591
|
+
// The forms plugin's button type has a destructive init() that replaces
|
|
592
|
+
// children with "Send" text when it can't find a single textnode child.
|
|
593
|
+
$('button').each((_, el) => {
|
|
594
|
+
if ($(el).closest('form').length === 0) {
|
|
595
|
+
$(el).attr('data-gjs-type', 'default');
|
|
596
|
+
}
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
// ── Force all <details> open so GrapeJS can see/edit answer content ─
|
|
600
|
+
// Also tag <details> and <summary> with data-gjs-type so GrapeJS uses
|
|
601
|
+
// our custom component types instead of treating them as opaque blocks.
|
|
602
|
+
$('details').each((_, el) => {
|
|
603
|
+
$(el).attr('open', '');
|
|
604
|
+
$(el).attr('data-gjs-type', 'details');
|
|
605
|
+
});
|
|
606
|
+
$('summary').each((_, el) => {
|
|
607
|
+
$(el).attr('data-gjs-type', 'summary');
|
|
608
|
+
|
|
609
|
+
// Wrap bare text nodes inside <summary> in a <span> so GrapeJS
|
|
610
|
+
// can select and inline-edit the question text. Without this,
|
|
611
|
+
// GrapeJS can't enter edit mode because the <summary> has mixed
|
|
612
|
+
// content (raw text + child SVG chevron icon).
|
|
613
|
+
const children = $(el).contents(); // includes text nodes
|
|
614
|
+
children.each((_, child) => {
|
|
615
|
+
if (child.type === 'text' && child.data.trim()) {
|
|
616
|
+
$(child).wrap('<span class="summary-text" data-gjs-editable="true"></span>');
|
|
617
|
+
}
|
|
618
|
+
});
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
// ── Preserve inline event handlers ──────────────────────────────────
|
|
622
|
+
// GrapeJS's getHtml() strips event handler attributes (onclick, onsubmit, etc.).
|
|
623
|
+
// Convert them to data-ht-* attributes that GrapeJS preserves, then convert
|
|
624
|
+
// them back on save. This keeps theme toggles, FAQ accordions, mobile nav,
|
|
625
|
+
// WhatsApp forms, and other interactive elements working after edit+save.
|
|
626
|
+
EVENT_ATTRS.forEach(attr => {
|
|
627
|
+
$(`body [${attr}]`).each((_, el) => {
|
|
628
|
+
const value = $(el).attr(attr);
|
|
629
|
+
if (value) {
|
|
630
|
+
$(el).attr(`data-ht-${attr}`, value);
|
|
631
|
+
$(el).removeAttr(attr);
|
|
632
|
+
}
|
|
633
|
+
});
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
// Strip <script> tags from body before sending to GrapeJS
|
|
637
|
+
// (GrapeJS can't handle scripts; the save endpoint re-appends
|
|
638
|
+
// the original body scripts so nothing is lost)
|
|
639
|
+
$('body > script').remove();
|
|
640
|
+
|
|
641
|
+
// Extract body content for GrapeJS editing
|
|
642
|
+
const bodyContent = $('body').html() || '';
|
|
643
|
+
|
|
644
|
+
// Extract body attributes (classes, etc.)
|
|
645
|
+
const bodyAttributes = {};
|
|
646
|
+
const bodyEl = $('body')[0];
|
|
647
|
+
if (bodyEl && bodyEl.attribs) {
|
|
648
|
+
Object.assign(bodyAttributes, bodyEl.attribs);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// Extract GrapeJS-specific CSS if previously saved
|
|
652
|
+
const gjsCss = $('style#gjs-editor-styles').html() || '';
|
|
653
|
+
|
|
654
|
+
res.json({
|
|
655
|
+
filename,
|
|
656
|
+
headContent,
|
|
657
|
+
bodyContent,
|
|
658
|
+
bodyAttributes,
|
|
659
|
+
canvasResources: {
|
|
660
|
+
cssLinks,
|
|
661
|
+
scriptSrcs,
|
|
662
|
+
inlineStyles,
|
|
663
|
+
inlineScripts,
|
|
664
|
+
},
|
|
665
|
+
gjsCss,
|
|
666
|
+
});
|
|
667
|
+
} catch (err) {
|
|
668
|
+
res.status(500).json({ error: 'Failed to load page', details: err.message });
|
|
669
|
+
}
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
// ─── API: Save a page ───────────────────────────────────────────────────────
|
|
673
|
+
|
|
674
|
+
app.post('/api/pages/*', async (req, res) => {
|
|
675
|
+
try {
|
|
676
|
+
const filename = decodeURIComponent(req.params[0]);
|
|
677
|
+
const filePath = resolveEditableHtmlPath(filename);
|
|
678
|
+
if (!filePath) {
|
|
679
|
+
return res.status(403).json({ error: 'Access denied' });
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// Security: prevent directory traversal
|
|
683
|
+
if (!isInsidePath(SITE_ROOT, filePath) || !filePath.endsWith('.html')) {
|
|
684
|
+
return res.status(403).json({ error: 'Access denied' });
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// Don't allow editing admin files
|
|
688
|
+
if (isInsidePath(ADMIN_DIR, filePath)) {
|
|
689
|
+
return res.status(403).json({ error: 'Cannot edit admin files' });
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
if (isExcludedEditablePath(filename)) {
|
|
693
|
+
return res.status(403).json({ error: 'Cannot edit duplicate or backup pages' });
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
if (!fs.existsSync(filePath)) {
|
|
697
|
+
return res.status(404).json({ error: 'File not found' });
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
const { htmlContent, cssContent } = req.body;
|
|
701
|
+
|
|
702
|
+
if (!htmlContent) {
|
|
703
|
+
return res.status(400).json({ error: 'Missing htmlContent in request body' });
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// Read the original file to preserve the <head> section AND body scripts
|
|
707
|
+
const originalHtml = fs.readFileSync(filePath, 'utf8');
|
|
708
|
+
const $ = cheerio.load(originalHtml, { decodeEntities: false });
|
|
709
|
+
|
|
710
|
+
// Preserve <script> tags from the original body — GrapeJS's getHtml()
|
|
711
|
+
// strips all scripts, so we save them and re-append after replacing body.
|
|
712
|
+
const bodyScripts = [];
|
|
713
|
+
$('body > script').each((_, el) => {
|
|
714
|
+
bodyScripts.push($.html(el));
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
// Update body content with what GrapeJS provides
|
|
718
|
+
$('body').html(htmlContent);
|
|
719
|
+
|
|
720
|
+
// Re-append the original body scripts
|
|
721
|
+
if (bodyScripts.length > 0) {
|
|
722
|
+
$('body').append('\n' + bodyScripts.join('\n'));
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// Extract base64 data URIs → save as files in assets/images/
|
|
726
|
+
const extractedCount = await extractAndSaveBase64Images($, SITE_ROOT);
|
|
727
|
+
if (extractedCount > 0) {
|
|
728
|
+
console.log(` 📦 Extracted ${extractedCount} base64 image(s) to assets/images/`);
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
// Strip data-gjs-type attributes that were added for the editor
|
|
732
|
+
// so the source HTML stays clean
|
|
733
|
+
$('body [data-gjs-type]').removeAttr('data-gjs-type');
|
|
734
|
+
|
|
735
|
+
// Strip public live-editor attributes/UI if a save comes from the page
|
|
736
|
+
// itself rather than the GrapeJS admin iframe.
|
|
737
|
+
$('body [data-buzl-public-editor-ui]').remove();
|
|
738
|
+
$('body [data-buzl-public-edit]').removeAttr('contenteditable spellcheck data-buzl-public-edit');
|
|
739
|
+
$('body').removeClass('buzl-public-editing overflow-hidden');
|
|
740
|
+
$('body .reveal.is-in').removeClass('is-in');
|
|
741
|
+
$('#siteHeader.shadow-lift').removeClass('shadow-lift');
|
|
742
|
+
$('#leadModal').addClass('hidden').removeClass('flex');
|
|
743
|
+
$('#mobileMenu').attr('hidden', '');
|
|
744
|
+
|
|
745
|
+
// ── Restore <details> open state ───────────────────────────────────
|
|
746
|
+
// The editor forces all <details> open for visibility. On save, the
|
|
747
|
+
// user controls the "open" attribute via the component trait. If the
|
|
748
|
+
// trait value is falsy / not present, remove the open attribute so the
|
|
749
|
+
// accordion starts collapsed. GrapeJS persists the `open` attribute
|
|
750
|
+
// in its HTML output only when the trait checkbox is checked.
|
|
751
|
+
// (No extra work needed — GrapeJS getHtml() includes open="" only
|
|
752
|
+
// when the attribute is set, so the trait already drives the output.)
|
|
753
|
+
|
|
754
|
+
// ── Restore inline event handlers ───────────────────────────────────
|
|
755
|
+
// Convert data-ht-onclick → onclick, data-ht-onsubmit → onsubmit, etc.
|
|
756
|
+
EVENT_ATTRS.forEach(attr => {
|
|
757
|
+
$(`body [data-ht-${attr}]`).each((_, el) => {
|
|
758
|
+
const value = $(el).attr(`data-ht-${attr}`);
|
|
759
|
+
if (value) {
|
|
760
|
+
$(el).attr(attr, value);
|
|
761
|
+
$(el).removeAttr(`data-ht-${attr}`);
|
|
762
|
+
}
|
|
763
|
+
});
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
// Remove any previously injected editor styles. Only keep genuinely custom
|
|
767
|
+
// GrapeJS CSS; its default reset belongs to the editor, not the live page.
|
|
768
|
+
$('style#gjs-editor-styles').remove();
|
|
769
|
+
if (shouldPersistGjsCss(cssContent)) {
|
|
770
|
+
$('head').append(`<style id="gjs-editor-styles">\n${cssContent}\n</style>`);
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
// Write the updated HTML back to the file
|
|
774
|
+
const updatedHtml = $.html();
|
|
775
|
+
writeHtmlWithBackup(filePath, updatedHtml);
|
|
776
|
+
|
|
777
|
+
res.json({ success: true, message: `Saved ${filename}` });
|
|
778
|
+
} catch (err) {
|
|
779
|
+
res.status(500).json({ error: 'Failed to save page', details: err.message });
|
|
780
|
+
}
|
|
781
|
+
});
|
|
782
|
+
|
|
783
|
+
// ─── STATIC: Serve the public website from site root ────────────────────────
|
|
784
|
+
|
|
785
|
+
// Unknown API paths must not fall through to similarly named files in the
|
|
786
|
+
// public website folder.
|
|
787
|
+
app.use('/api', (req, res) => {
|
|
788
|
+
res.status(404).json({ error: 'API endpoint not found' });
|
|
789
|
+
});
|
|
790
|
+
|
|
791
|
+
// Block private development files before public static serving.
|
|
792
|
+
app.use((req, res, next) => {
|
|
793
|
+
let decodedPath;
|
|
794
|
+
try {
|
|
795
|
+
decodedPath = decodeURIComponent(req.path).replace(/\\/g, '/');
|
|
796
|
+
} catch {
|
|
797
|
+
return res.status(400).send('Bad Request');
|
|
798
|
+
}
|
|
799
|
+
const segments = decodedPath.toLowerCase().split('/').filter(Boolean);
|
|
800
|
+
const blockedSegments = new Set(['admin', 'node_modules', '.git', '.buzl', 'tests', 'coverage']);
|
|
801
|
+
const blockedFiles = new Set(['.env', '.env.example', '.env_example', 'package.json', 'package-lock.json', 'server.js']);
|
|
802
|
+
const filename = segments[segments.length - 1] || '';
|
|
803
|
+
if (segments.some((segment) => segment.startsWith('.') || blockedSegments.has(segment))
|
|
804
|
+
|| blockedFiles.has(filename)
|
|
805
|
+
|| /\.(?:cmd|bat|sh|log|md|test\.js)$/.test(filename)) {
|
|
806
|
+
return res.status(404).send('Not Found');
|
|
807
|
+
}
|
|
808
|
+
return next();
|
|
809
|
+
});
|
|
810
|
+
|
|
811
|
+
app.use(express.static(SITE_ROOT, {
|
|
812
|
+
index: 'index.html',
|
|
813
|
+
extensions: ['html']
|
|
814
|
+
}));
|
|
815
|
+
|
|
816
|
+
// ─── HELPERS ────────────────────────────────────────────────────────────────
|
|
817
|
+
|
|
818
|
+
// ── Extract base64 data URIs from HTML, save as files, update src ───────────
|
|
819
|
+
// Scans all <img> and elements with style background-image for data: URIs.
|
|
820
|
+
// Saves each unique image to assets/images/ and replaces the data URI with
|
|
821
|
+
// the relative file path.
|
|
822
|
+
function resolveEditableHtmlPath(filename) {
|
|
823
|
+
const candidate = path.resolve(SITE_ROOT, filename);
|
|
824
|
+
if (!candidate.toLowerCase().endsWith('.html') || !isInsidePath(SITE_ROOT, candidate)) return null;
|
|
825
|
+
try {
|
|
826
|
+
const realRoot = fs.realpathSync(SITE_ROOT);
|
|
827
|
+
const realCandidate = fs.realpathSync(candidate);
|
|
828
|
+
return isInsidePath(realRoot, realCandidate) ? realCandidate : null;
|
|
829
|
+
} catch {
|
|
830
|
+
return candidate;
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
function writeHtmlWithBackup(filePath, html) {
|
|
835
|
+
const relative = path.relative(SITE_ROOT, filePath);
|
|
836
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
837
|
+
const backupDir = path.join(SITE_ROOT, '.buzl', 'backups', path.dirname(relative));
|
|
838
|
+
const parsed = path.parse(filePath);
|
|
839
|
+
const backupPath = path.join(backupDir, parsed.name + '-' + stamp + parsed.ext);
|
|
840
|
+
const temporaryPath = filePath + '.buzl-' + process.pid + '-' + Date.now() + '.tmp';
|
|
841
|
+
fs.mkdirSync(backupDir, { recursive: true });
|
|
842
|
+
fs.copyFileSync(filePath, backupPath);
|
|
843
|
+
try {
|
|
844
|
+
fs.writeFileSync(temporaryPath, html, 'utf8');
|
|
845
|
+
fs.renameSync(temporaryPath, filePath);
|
|
846
|
+
} finally {
|
|
847
|
+
if (fs.existsSync(temporaryPath)) fs.unlinkSync(temporaryPath);
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
function shouldPersistGjsCss(cssContent) {
|
|
852
|
+
if (!cssContent || !cssContent.trim()) return false;
|
|
853
|
+
|
|
854
|
+
const normalized = cssContent
|
|
855
|
+
.replace(/\/\*[\s\S]*?\*\//g, '')
|
|
856
|
+
.replace(/\s+/g, ' ')
|
|
857
|
+
.replace(/\s*([{}:;,])\s*/g, '$1')
|
|
858
|
+
.trim();
|
|
859
|
+
|
|
860
|
+
return normalized !== '*{box-sizing:border-box;}body{margin:0;}';
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
const MIME_TO_EXT = {
|
|
864
|
+
'image/png': '.png',
|
|
865
|
+
'image/jpeg': '.jpg',
|
|
866
|
+
'image/jpg': '.jpg',
|
|
867
|
+
'image/gif': '.gif',
|
|
868
|
+
'image/webp': '.webp',
|
|
869
|
+
'image/svg+xml': '.svg',
|
|
870
|
+
'image/avif': '.avif',
|
|
871
|
+
};
|
|
872
|
+
|
|
873
|
+
async function extractAndSaveBase64Images($, siteRoot) {
|
|
874
|
+
const imagesDir = path.join(siteRoot, 'assets', 'images');
|
|
875
|
+
|
|
876
|
+
// Ensure the directory exists
|
|
877
|
+
if (!fs.existsSync(imagesDir)) {
|
|
878
|
+
fs.mkdirSync(imagesDir, { recursive: true });
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
// Track data-hash → filename so duplicate images reuse the same file
|
|
882
|
+
const saved = new Map();
|
|
883
|
+
let count = 0;
|
|
884
|
+
|
|
885
|
+
// Regex to match data:image URIs (works for src attributes and CSS urls)
|
|
886
|
+
const dataUriRegex = /^data:(image\/[a-zA-Z+]+);base64,(.+)$/s;
|
|
887
|
+
|
|
888
|
+
// 1. Process <img> tags with data: src
|
|
889
|
+
const imageElements = $('img[src^="data:image"]').toArray();
|
|
890
|
+
for (const el of imageElements) {
|
|
891
|
+
const src = $(el).attr('src');
|
|
892
|
+
const match = src.match(dataUriRegex);
|
|
893
|
+
if (!match) continue;
|
|
894
|
+
|
|
895
|
+
const mimeType = match[1];
|
|
896
|
+
const base64Data = match[2];
|
|
897
|
+
|
|
898
|
+
// Try to derive a meaningful filename from alt, title, or data-filename
|
|
899
|
+
const nameHint = $(el).attr('data-filename')
|
|
900
|
+
|| $(el).attr('alt')
|
|
901
|
+
|| $(el).attr('title')
|
|
902
|
+
|| '';
|
|
903
|
+
|
|
904
|
+
const filePath = await saveBase64Image(base64Data, mimeType, imagesDir, saved, nameHint);
|
|
905
|
+
if (filePath) {
|
|
906
|
+
$(el).attr('src', filePath);
|
|
907
|
+
count++;
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
// 2. Process any element with inline style containing base64 background-image
|
|
912
|
+
const styleElements = $('[style*="data:image"]').toArray();
|
|
913
|
+
for (const el of styleElements) {
|
|
914
|
+
let style = $(el).attr('style');
|
|
915
|
+
if (!style) continue;
|
|
916
|
+
|
|
917
|
+
const matches = [...style.matchAll(/url\(\s*['"]?(data:(image\/[a-zA-Z+]+);base64,([^'")\s]+))['"]?\s*\)/g)];
|
|
918
|
+
for (const match of matches) {
|
|
919
|
+
const filePath = await saveBase64Image(match[3], match[2], imagesDir, saved);
|
|
920
|
+
if (!filePath) continue;
|
|
921
|
+
style = style.replace(match[0], `url('${filePath}')`);
|
|
922
|
+
count++;
|
|
923
|
+
}
|
|
924
|
+
$(el).attr('style', style);
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
return count;
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
async function saveBase64Image(base64Data, mimeType, imagesDir, saved, nameHint = '') {
|
|
931
|
+
// Create a short hash of the content for dedup
|
|
932
|
+
const hash = crypto.createHash('md5').update(base64Data.slice(0, 2048)).digest('hex').slice(0, 8);
|
|
933
|
+
|
|
934
|
+
if (saved.has(hash)) {
|
|
935
|
+
return saved.get(hash);
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
// Try to build a meaningful filename from the hint
|
|
939
|
+
let baseName = '';
|
|
940
|
+
if (nameHint) {
|
|
941
|
+
baseName = nameHint.replace(/\.[a-zA-Z]{2,5}$/, '');
|
|
942
|
+
baseName = baseName
|
|
943
|
+
.toLowerCase()
|
|
944
|
+
.replace(/[^a-z0-9_-]/g, '-')
|
|
945
|
+
.replace(/-+/g, '-')
|
|
946
|
+
.replace(/^-|-$/g, '');
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
if (!baseName) baseName = 'img';
|
|
950
|
+
|
|
951
|
+
// Include the content hash even when a human-friendly hint exists so two
|
|
952
|
+
// different images with the same alt text never overwrite or reuse one file.
|
|
953
|
+
const outputName = `${baseName}-${hash}`;
|
|
954
|
+
const outputStem = path.join(imagesDir, outputName);
|
|
955
|
+
const avifPath = `${outputStem}.avif`;
|
|
956
|
+
const avifUrl = `/assets/images/${outputName}.avif`;
|
|
957
|
+
|
|
958
|
+
if (fs.existsSync(avifPath)) {
|
|
959
|
+
saved.set(hash, avifUrl);
|
|
960
|
+
return avifUrl;
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
const buffer = Buffer.from(base64Data, 'base64');
|
|
964
|
+
try {
|
|
965
|
+
const optimized = await compressImage(buffer, outputStem);
|
|
966
|
+
const optimizedUrl = `/assets/images/${path.basename(optimized.path)}`;
|
|
967
|
+
console.log(` 💾 Saved optimized base64 image → ${optimizedUrl} (${(optimized.size / 1024).toFixed(1)} KB)`);
|
|
968
|
+
saved.set(hash, optimizedUrl);
|
|
969
|
+
return optimizedUrl;
|
|
970
|
+
} catch (err) {
|
|
971
|
+
// Preserve valid source data if Sharp cannot convert a supported edge case.
|
|
972
|
+
const fallbackExt = MIME_TO_EXT[mimeType] || '.png';
|
|
973
|
+
const fallbackName = `${outputName}${fallbackExt}`;
|
|
974
|
+
const fallbackPath = path.join(imagesDir, fallbackName);
|
|
975
|
+
if (!fs.existsSync(fallbackPath)) fs.writeFileSync(fallbackPath, buffer);
|
|
976
|
+
const fallbackUrl = `/assets/images/${fallbackName}`;
|
|
977
|
+
console.warn(` ⚠️ AVIF conversion failed; kept original image → ${fallbackUrl}: ${err.message}`);
|
|
978
|
+
saved.set(hash, fallbackUrl);
|
|
979
|
+
return fallbackUrl;
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
function findHtmlFiles(dir, base = '') {
|
|
984
|
+
const results = [];
|
|
985
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
986
|
+
const skipDirs = new Set([
|
|
987
|
+
'admin',
|
|
988
|
+
'admin1',
|
|
989
|
+
'new',
|
|
990
|
+
'node_modules',
|
|
991
|
+
'.backups',
|
|
992
|
+
'.git',
|
|
993
|
+
'tests',
|
|
994
|
+
]);
|
|
995
|
+
|
|
996
|
+
for (const entry of entries) {
|
|
997
|
+
const relPath = base ? `${base}/${entry.name}` : entry.name;
|
|
998
|
+
|
|
999
|
+
// Skip admin tools, duplicate exported copies, dependencies, backups, and tests.
|
|
1000
|
+
if (skipDirs.has(entry.name)) {
|
|
1001
|
+
continue;
|
|
1002
|
+
}
|
|
1003
|
+
if (entry.name.startsWith('.')) continue;
|
|
1004
|
+
if (entry.name.startsWith('_backups')) continue;
|
|
1005
|
+
|
|
1006
|
+
if (entry.isDirectory()) {
|
|
1007
|
+
results.push(...findHtmlFiles(path.join(dir, entry.name), relPath));
|
|
1008
|
+
} else if (entry.isFile() && entry.name.endsWith('.html')) {
|
|
1009
|
+
results.push(relPath);
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
return results;
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
function sortEditablePages(pages) {
|
|
1017
|
+
return pages.sort((a, b) => {
|
|
1018
|
+
if (a === 'index.html') return -1;
|
|
1019
|
+
if (b === 'index.html') return 1;
|
|
1020
|
+
return a.localeCompare(b);
|
|
1021
|
+
});
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
function isExcludedEditablePath(filename) {
|
|
1025
|
+
const firstSegment = filename.replace(/\\/g, '/').split('/')[0];
|
|
1026
|
+
return ['admin', 'admin1', 'new', 'node_modules', 'tests'].includes(firstSegment);
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
function isInsidePath(parentDir, targetPath) {
|
|
1030
|
+
const relative = path.relative(parentDir, targetPath);
|
|
1031
|
+
return relative === '' || (!!relative && !relative.startsWith('..') && !path.isAbsolute(relative));
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
// ─── START ──────────────────────────────────────────────────────────────────
|
|
1035
|
+
|
|
1036
|
+
// Only start the server when run directly (not when required by tests)
|
|
1037
|
+
if (require.main === module) {
|
|
1038
|
+
app.listen(PORT, HOST, () => {
|
|
1039
|
+
console.log(`\n 🌐 ${siteConfig.name || 'Website'}: http://localhost:${PORT}`);
|
|
1040
|
+
console.log(` ✏️ GrapeJS Admin editor: http://localhost:${PORT}/admin`);
|
|
1041
|
+
console.log(`\n Press Ctrl+C to stop.\n`);
|
|
1042
|
+
});
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
module.exports = app;
|