@mahe_pkm/buzl-html-editor 0.1.0 → 0.2.1

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