@rettangoli/sites 0.2.7 → 1.0.0-rc10

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/src/cli/watch.js CHANGED
@@ -1,70 +1,98 @@
1
1
  import fs, { watch, existsSync } from 'node:fs';
2
2
  import http from 'node:http';
3
3
  import path from 'node:path';
4
- import { pathToFileURL } from 'node:url';
5
4
  import { WebSocketServer } from 'ws';
6
5
  import { buildSite } from './build.js';
7
- import { createScreenshotCapture } from '../screenshot.js';
8
6
  import { loadSiteConfig } from '../utils/loadSiteConfig.js';
9
7
 
10
- // Client script to inject into HTML pages
11
- const CLIENT_SCRIPT = `
12
- <script>
13
- (function() {
14
- console.log('šŸ”Œ Connecting to WebSocket...');
15
- const ws = new WebSocket('ws://' + location.host);
16
-
17
- ws.onopen = () => {
18
- console.log('āœ… WebSocket connected');
19
- };
20
-
21
- ws.onmessage = (event) => {
22
- console.log('šŸ“Ø Message received:', event.data);
23
- const data = JSON.parse(event.data);
24
-
25
- if (data.type === 'reload-current') {
26
- console.log('šŸ”„ Fetching updated page...');
27
-
8
+ const RELOAD_MODES = new Set(['body', 'full']);
9
+
10
+ export function createClientScript(reloadMode = 'body') {
11
+ const shouldUseBodyReplacement = reloadMode === 'body';
12
+ const reloadSnippet = shouldUseBodyReplacement
13
+ ? `
28
14
  // Fetch the current page's HTML
29
15
  fetch(window.location.href)
30
16
  .then(response => response.text())
31
17
  .then(html => {
32
- console.log('šŸ“„ Received updated HTML');
33
-
34
18
  // Parse the new HTML
35
19
  const parser = new DOMParser();
36
20
  const newDoc = parser.parseFromString(html, 'text/html');
37
-
21
+
38
22
  // Replace entire body content
39
23
  document.body.innerHTML = newDoc.body.innerHTML;
40
-
41
- console.log('āœ… Hot reload complete');
42
24
  })
43
25
  .catch(err => {
44
- console.error('āŒ Failed to fetch updated page:', err);
26
+ console.error('Hot reload failed:', err);
45
27
  // Fallback to full reload
46
28
  window.location.reload();
47
29
  });
30
+ `
31
+ : `
32
+ window.location.reload();
33
+ `;
34
+
35
+ return `
36
+ <script>
37
+ (function() {
38
+ const wsProtocol = location.protocol === 'https:' ? 'wss://' : 'ws://';
39
+ const ws = new WebSocket(wsProtocol + location.host);
40
+
41
+ ws.onmessage = (event) => {
42
+ const data = JSON.parse(event.data);
43
+
44
+ if (data.type === 'reload-current') {
45
+ ${reloadSnippet}
48
46
  }
49
47
  };
50
-
48
+
51
49
  ws.onclose = () => {
52
- console.log('āŒ Lost connection to dev server. Reloading in 1s...');
53
50
  setTimeout(() => location.reload(), 1000);
54
51
  };
55
-
56
- ws.onerror = (err) => {
57
- console.error('āŒ WebSocket error:', err);
58
- };
59
52
  })();
60
53
  </script>
61
54
  `;
55
+ }
56
+
57
+ export function getContentType(ext) {
58
+ const types = {
59
+ '.html': 'text/html; charset=utf-8',
60
+ '.css': 'text/css; charset=utf-8',
61
+ '.js': 'application/javascript; charset=utf-8',
62
+ '.json': 'application/json; charset=utf-8',
63
+ '.txt': 'text/plain; charset=utf-8',
64
+ '.md': 'text/plain; charset=utf-8',
65
+ '.png': 'image/png',
66
+ '.jpg': 'image/jpeg',
67
+ '.jpeg': 'image/jpeg',
68
+ '.gif': 'image/gif',
69
+ '.svg': 'image/svg+xml',
70
+ '.ico': 'image/x-icon',
71
+ '.webp': 'image/webp'
72
+ };
73
+ return types[ext] || 'application/octet-stream';
74
+ }
75
+
76
+ function createLogger(quiet = false) {
77
+ return {
78
+ log: (...args) => {
79
+ if (!quiet) {
80
+ console.log(...args);
81
+ }
82
+ },
83
+ error: (...args) => {
84
+ console.error(...args);
85
+ }
86
+ };
87
+ }
62
88
 
63
89
  class DevServer {
64
- constructor(port = 3001) {
90
+ constructor(port = 3001, siteDir = '_site', logger = createLogger(false), reloadMode = 'body') {
65
91
  this.port = port;
66
92
  this.clients = new Set();
67
- this.siteDir = '_site';
93
+ this.siteDir = siteDir;
94
+ this.logger = logger;
95
+ this.clientScript = createClientScript(reloadMode);
68
96
  }
69
97
 
70
98
  start() {
@@ -75,39 +103,29 @@ class DevServer {
75
103
 
76
104
  // Create WebSocket server
77
105
  this.wss = new WebSocketServer({ server: this.httpServer });
78
- console.log('WebSocket server created');
79
106
 
80
107
  this.wss.on('connection', (ws) => {
81
- console.log('āœ… Client connected. Total clients:', this.clients.size + 1);
82
108
  this.clients.add(ws);
83
109
 
84
110
  ws.on('close', () => {
85
- console.log('āŒ Client disconnected. Remaining clients:', this.clients.size - 1);
86
111
  this.clients.delete(ws);
87
112
  });
88
113
 
89
114
  ws.on('error', (err) => {
90
- console.error('āŒ WebSocket error:', err);
115
+ this.logger.error('WebSocket error:', err);
91
116
  this.clients.delete(ws);
92
117
  });
93
118
  });
94
119
 
95
120
  // Start listening
96
121
  this.httpServer.listen(this.port, '0.0.0.0', () => {
97
- console.log(`\n Dev server running at:\n`);
98
- console.log(` > Local: http://localhost:${this.port}/`);
99
- console.log(` > Network: http://0.0.0.0:${this.port}/\n`);
100
- console.log(` Hot reload enabled (body replacement)\n`);
122
+ this.logger.log(`Dev server: http://localhost:${this.port}/`);
101
123
  });
102
124
  }
103
125
 
104
126
  handleRequest(req, res) {
105
127
  const urlParts = req.url.split('?');
106
128
  let urlPath = urlParts[0];
107
- const queryString = urlParts[1] || '';
108
-
109
- // Check if this is a screenshot request
110
- const isScreenshotRequest = queryString.includes('screenshot=true');
111
129
 
112
130
  // Default to index.html for root
113
131
  if (urlPath === '/') {
@@ -150,7 +168,7 @@ class DevServer {
150
168
  // Try to serve index.html from the directory
151
169
  const indexPath = path.join(filePath, 'index.html');
152
170
  if (existsSync(indexPath)) {
153
- return this.serveFile(indexPath, res, isScreenshotRequest);
171
+ return this.serveFile(indexPath, res);
154
172
  } else {
155
173
  res.writeHead(404);
156
174
  res.end('404 Not Found');
@@ -159,56 +177,43 @@ class DevServer {
159
177
  }
160
178
 
161
179
  // Serve the file
162
- this.serveFile(filePath, res, isScreenshotRequest);
180
+ this.serveFile(filePath, res);
163
181
  }
164
182
 
165
- serveFile(filePath, res, skipWebSocket = false) {
183
+ serveFile(filePath, res) {
166
184
  const ext = path.extname(filePath);
167
- const contentType = this.getContentType(ext);
185
+ const contentType = getContentType(ext);
168
186
 
169
187
  try {
170
188
  let content = fs.readFileSync(filePath);
171
189
 
172
- // Inject client script into HTML files (unless it's a screenshot request)
173
- if (ext === '.html' && !skipWebSocket) {
190
+ // Inject client script into HTML files
191
+ if (ext === '.html') {
174
192
  content = content.toString();
175
193
  // Inject before </body> or </html> or at the end
176
194
  if (content.includes('</body>')) {
177
- content = content.replace('</body>', CLIENT_SCRIPT + '</body>');
195
+ content = content.replace('</body>', this.clientScript + '</body>');
178
196
  } else if (content.includes('</html>')) {
179
- content = content.replace('</html>', CLIENT_SCRIPT + '</html>');
197
+ content = content.replace('</html>', this.clientScript + '</html>');
180
198
  } else {
181
- content = content + CLIENT_SCRIPT;
199
+ content = content + this.clientScript;
182
200
  }
183
201
  }
184
202
 
185
- res.writeHead(200, { 'Content-Type': contentType });
203
+ const headers = { 'Content-Type': contentType };
204
+ if (ext === '.md' || ext === '.txt') {
205
+ headers['Content-Disposition'] = 'inline';
206
+ }
207
+ res.writeHead(200, headers);
186
208
  res.end(content);
187
209
  } catch (err) {
188
- console.error('Error serving file:', err);
210
+ this.logger.error('Error serving file:', err);
189
211
  res.writeHead(500);
190
212
  res.end('Internal Server Error');
191
213
  }
192
214
  }
193
215
 
194
- getContentType(ext) {
195
- const types = {
196
- '.html': 'text/html',
197
- '.css': 'text/css',
198
- '.js': 'application/javascript',
199
- '.json': 'application/json',
200
- '.png': 'image/png',
201
- '.jpg': 'image/jpeg',
202
- '.gif': 'image/gif',
203
- '.svg': 'image/svg+xml',
204
- '.ico': 'image/x-icon'
205
- };
206
- return types[ext] || 'application/octet-stream';
207
- }
208
-
209
216
  reloadAll() {
210
- console.log('šŸ“¤ Sending reload message to', this.clients.size, 'clients');
211
-
212
217
  // Send a simple reload command to all clients
213
218
  const message = JSON.stringify({
214
219
  type: 'reload-current'
@@ -221,63 +226,35 @@ class DevServer {
221
226
  sentCount++;
222
227
  }
223
228
  });
224
-
225
- console.log('āœ… Reload message sent to', sentCount, 'clients');
229
+ this.logger.log(`Reloaded ${sentCount} client(s)`);
226
230
  }
227
231
 
228
232
  close() {
229
- this.wss.close();
230
- this.httpServer.close();
233
+ if (this.wss) this.wss.close();
234
+ if (this.httpServer) this.httpServer.close();
231
235
  }
232
236
  }
233
237
 
234
238
  // File watcher setup
235
- const setupWatcher = (directory, options, server, screenshotCapture) => {
239
+ const setupWatcher = (directory, options, server, logger) => {
236
240
  let debounceTimer = null;
237
- let pendingFiles = new Set();
241
+ const outputRootDir = path.resolve(options.rootDir, options.outputPath || '_site');
238
242
 
239
243
  const processChanges = async () => {
240
- const files = [...pendingFiles];
241
- pendingFiles.clear();
242
-
243
- console.log('Rebuilding site...');
244
+ logger.log('Rebuilding site...');
244
245
  try {
245
- // Always reload config on rebuild to pick up function changes
246
- const config = await loadSiteConfig(options.rootDir, true, true);
247
-
248
- const currentOptions = {
249
- ...options,
250
- md: config.md || options.md,
251
- functions: config.functions || options.functions || {}
252
- };
253
-
254
- await buildSite({ ...currentOptions, quiet: true });
255
- console.log('Rebuild complete');
246
+ await buildSite({
247
+ rootDir: options.rootDir,
248
+ outputPath: options.outputPath,
249
+ quiet: true
250
+ });
251
+ logger.log('Rebuild complete');
256
252
 
257
253
  // Just reload all clients - they'll reload their current page
258
- console.log('šŸ”„ Reloading all connected clients');
259
254
  server.reloadAll();
260
255
 
261
- // If screenshots are enabled and pages were changed, capture screenshots
262
- const pageFiles = files.filter(file =>
263
- (file.includes('pages/') || file.startsWith('pages/')) &&
264
- (file.endsWith('.md') || file.endsWith('.yaml') || file.endsWith('.yml'))
265
- );
266
- if (screenshotCapture && pageFiles.length > 0) {
267
- // Wait a bit for the server to be ready with new content
268
- await new Promise(resolve => setTimeout(resolve, 1000));
269
-
270
- console.log('šŸ“ø Capturing screenshots for changed pages...');
271
- // Capture screenshots for changed pages
272
- for (const file of pageFiles) {
273
- // The file is already in the format "pages/creator/about.yaml"
274
- console.log(`šŸ“ø Capturing screenshot for: ${file}`);
275
- await screenshotCapture.capturePageScreenshot(file);
276
- }
277
- }
278
-
279
256
  } catch (error) {
280
- console.error('Error during rebuild:', error);
257
+ logger.error('Error during rebuild:', error);
281
258
  }
282
259
  };
283
260
 
@@ -291,29 +268,12 @@ const setupWatcher = (directory, options, server, screenshotCapture) => {
291
268
  return;
292
269
  }
293
270
 
294
- // Skip _site directory if it somehow gets included
295
- if (filename.includes('_site/')) {
271
+ const changedPath = path.resolve(directory, filename);
272
+ if (changedPath === outputRootDir || changedPath.startsWith(outputRootDir + path.sep)) {
296
273
  return;
297
274
  }
298
275
 
299
- // For static directory, only rebuild for content files, not binary files
300
- const isStaticDir = directory.endsWith('/static') || directory.includes('/static/');
301
- if (isStaticDir) {
302
- const ext = path.extname(filename).toLowerCase();
303
- const binaryExts = ['.png', '.jpg', '.jpeg', '.gif', '.ico', '.pdf', '.zip', '.woff', '.woff2', '.ttf', '.eot'];
304
-
305
- if (binaryExts.includes(ext)) {
306
- // For binary files in static, just copy them without full rebuild
307
- console.log(`šŸ“ Static file changed: ${directory}/${filename} (skipping rebuild)`);
308
- return;
309
- }
310
- }
311
-
312
- console.log(`Detected ${event} in ${filename}`);
313
-
314
- // Add to pending files for rebuild
315
- const fullPath = path.join(directory, filename);
316
- pendingFiles.add(fullPath);
276
+ logger.log(`Detected ${event} in ${filename}`);
317
277
 
318
278
  if (debounceTimer) {
319
279
  clearTimeout(debounceTimer);
@@ -325,82 +285,96 @@ const setupWatcher = (directory, options, server, screenshotCapture) => {
325
285
  );
326
286
  };
327
287
 
288
+ const setupConfigWatcher = (rootDir, options, server) => {
289
+ let debounceTimer = null;
290
+ const logger = createLogger(options.quiet);
291
+
292
+ watch(rootDir, { recursive: false }, async (event, filename) => {
293
+ if (!filename) {
294
+ return;
295
+ }
296
+
297
+ const normalized = String(filename).replace(/\\/g, '/');
298
+ const baseName = path.basename(normalized);
299
+ if (baseName !== 'sites.config.yaml' && baseName !== 'sites.config.yml') {
300
+ return;
301
+ }
302
+
303
+ logger.log(`Detected ${event} in ${baseName}`);
304
+
305
+ if (debounceTimer) {
306
+ clearTimeout(debounceTimer);
307
+ }
308
+
309
+ debounceTimer = setTimeout(async () => {
310
+ logger.log('Rebuilding site...');
311
+ try {
312
+ await buildSite({
313
+ rootDir: options.rootDir,
314
+ outputPath: options.outputPath,
315
+ quiet: true
316
+ });
317
+ logger.log('Rebuild complete');
318
+ server.reloadAll();
319
+ } catch (error) {
320
+ logger.error('Error during rebuild:', error);
321
+ }
322
+ }, 10);
323
+ });
324
+ };
325
+
328
326
  // Main watch function
329
327
  const watchSite = async (options = {}) => {
330
328
  const {
331
329
  port = 3001,
332
330
  rootDir = process.cwd(),
333
- screenshots = false
331
+ outputPath = '_site',
332
+ quiet = false,
333
+ reloadMode = 'body'
334
334
  } = options;
335
+ const normalizedReloadMode = String(reloadMode).toLowerCase();
336
+ if (!RELOAD_MODES.has(normalizedReloadMode)) {
337
+ throw new Error(`Invalid reload mode "${reloadMode}". Allowed values: body, full.`);
338
+ }
339
+ const logger = createLogger(quiet);
335
340
 
336
341
  // Load config file
337
- console.log(`šŸ“ Current working directory: ${process.cwd()}`);
338
- console.log(`šŸ“ rootDir parameter: ${rootDir}`);
339
- const config = await loadSiteConfig(rootDir, false);
340
-
341
- if (Object.keys(config).length > 0) {
342
- console.log('āœ… Loaded sites.config.js');
343
- if (config.md) {
344
- console.log('āœ… Custom md function found');
345
- } else {
346
- console.log('ā„¹ļø No custom md function in config');
347
- }
348
- if (config.functions) {
349
- console.log(`āœ… Found ${Object.keys(config.functions).length} custom function(s)`);
350
- }
351
- } else {
352
- console.log('ā„¹ļø No sites.config.js found, using defaults');
353
- }
342
+ await loadSiteConfig(rootDir, true, true);
354
343
 
355
344
  // Do initial build with config
356
- console.log('Starting initial build...');
357
- await buildSite({
358
- rootDir,
359
- md: config.md,
360
- functions: config.functions || {}
361
- });
362
- console.log('Initial build complete');
345
+ logger.log('Starting initial build...');
346
+ await buildSite({ rootDir, outputPath, quiet: true });
347
+ logger.log('Initial build complete');
363
348
 
364
349
  // Start custom dev server
365
- const server = new DevServer(port);
350
+ const server = new DevServer(port, path.resolve(rootDir, outputPath), logger, normalizedReloadMode);
366
351
  server.start();
367
352
 
368
- // Initialize screenshot capture if enabled
369
- let screenshotCapture = null;
370
- if (screenshots) {
371
- console.log('\nšŸ“ø Screenshot capture enabled');
372
- screenshotCapture = await createScreenshotCapture(port);
373
- }
374
-
375
353
  // Watch all relevant directories
376
- const dirsToWatch = ['data', 'templates', 'partials', 'pages'];
354
+ const dirsToWatch = ['data', 'templates', 'partials', 'pages', 'static'];
377
355
 
378
356
  dirsToWatch.forEach(dir => {
379
357
  const dirPath = path.join(rootDir, dir);
380
358
  if (existsSync(dirPath)) {
381
- console.log(`šŸ‘ļø Watching: ${dir}/`);
359
+ logger.log(`Watching: ${dir}/`);
382
360
  setupWatcher(dirPath, {
383
361
  rootDir,
384
- md: config.md,
385
- functions: config.functions || {}
386
- }, server, screenshotCapture);
362
+ outputPath
363
+ }, server, logger);
387
364
  }
388
365
  });
389
366
 
367
+ logger.log('Watching: sites.config.yaml');
368
+ setupConfigWatcher(rootDir, { rootDir, outputPath, quiet }, server);
369
+
390
370
  // Handle process termination
391
371
  process.on('SIGINT', async () => {
392
- console.log('\nShutting down server...');
393
- if (screenshotCapture) {
394
- await screenshotCapture.close();
395
- }
372
+ logger.log('\nShutting down server...');
396
373
  server.close();
397
374
  process.exit();
398
375
  });
399
376
 
400
377
  process.on('SIGTERM', async () => {
401
- if (screenshotCapture) {
402
- await screenshotCapture.close();
403
- }
404
378
  server.close();
405
379
  process.exit();
406
380
  });