@kenjura/ursa 0.86.0 → 0.88.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/serve.js CHANGED
@@ -1,874 +1,907 @@
1
- import express from "express";
2
- import compression from "compression";
3
- import watch from "node-watch";
4
- import { generate, regenerateAffectedDocuments, clearWatchCache, clearScriptCache, clearStyleCache } from "./jobs/generate.js";
5
- import { join, resolve, dirname, basename } from "path";
6
- import fs from "fs";
7
- import { promises } from "fs";
8
- import { copy as copyDir, outputFile } from "fs-extra";
9
- import { processImage } from "./helper/imageProcessor.js";
10
- import { watchModeCache } from "./helper/build/watchCache.js";
11
- import { dependencyTracker } from "./helper/dependencyTracker.js";
12
- import { bundleMetaTemplateAssets, clearMetaBundleCache } from "./helper/assetBundler.js";
13
- import { getTemplates, copyMetaAssets } from "./helper/build/templates.js";
14
- import { isInsideTemplatesFolder, reconcileByTemplate } from "./helper/documentTemplates.js";
15
- import { WebSocketServer } from "ws";
16
- import { createServer } from "http";
17
- import { resolvePort } from "./helper/portUtils.js";
18
- const { readdir, mkdir, readFile, copyFile } = promises;
19
-
20
- // WebSocket server for hot reloading
21
- let wss = null;
22
-
23
- /**
24
- * Map of WebSocket client → current page URL path (e.g. '/campaigns/abs/index.html')
25
- * Updated when clients send { type: 'url', url: '...' } messages.
26
- */
27
- const clientUrls = new Map();
28
-
29
- /**
30
- * Get URL paths that connected WebSocket clients are currently viewing.
31
- * @returns {string[]} Array of unique URL paths
32
- */
33
- function getClientViewedUrls() {
34
- const urls = new Set();
35
- for (const [client, url] of clientUrls) {
36
- if (client.readyState === 1 && url) urls.add(url);
37
- }
38
- return [...urls];
39
- }
40
-
41
- /**
42
- * Normalize a URL path for comparison.
43
- * Converts /path/index.html → /path/, /path.html → /path.html
44
- * Strips trailing whitespace. Ensures leading /.
45
- * @param {string} url
46
- * @returns {string}
47
- */
48
- function normalizeUrl(url) {
49
- if (!url) return '/';
50
- let u = url.trim();
51
- if (!u.startsWith('/')) u = '/' + u;
52
- // /foo/index.html → /foo/
53
- if (u.endsWith('/index.html')) u = u.slice(0, -10);
54
- // Ensure trailing slash for directory-like paths (no extension)
55
- if (!u.includes('.') && !u.endsWith('/')) u = u + '/';
56
- return u;
57
- }
58
-
59
- /**
60
- * Convert a source file path to the URL path it would produce,
61
- * normalized for comparison with client URLs.
62
- * e.g. /Users/.../docs/campaigns/abs/index.mdx → /campaigns/abs/
63
- * @param {string} docPath - Absolute source path
64
- * @param {string} sourceDir - Absolute source directory (with trailing slash)
65
- * @returns {string} Normalized URL path
66
- */
67
- function docPathToUrl(docPath, sourceDir) {
68
- const normalizedSource = sourceDir.endsWith('/') ? sourceDir : sourceDir + '/';
69
- const rawUrl = '/' + docPath.replace(normalizedSource, '').replace(/\.(md|mdx|txt|yml|yaml)$/, '.html');
70
- return normalizeUrl(rawUrl);
71
- }
72
-
73
- /**
74
- * Return all URL paths a source file maps to. Most files map to a single URL,
75
- * but folder-named files (e.g. aletheia/aletheia.md) are promoted to
76
- * <folder>/index.html during the build, so they also serve the folder URL.
77
- * @param {string} docPath - Absolute source path
78
- * @param {string} sourceDir - Absolute source directory (with trailing slash)
79
- * @returns {string[]} Normalized URL paths
80
- */
81
- function docPathToUrls(docPath, sourceDir) {
82
- const urls = [docPathToUrl(docPath, sourceDir)];
83
- const ext = docPath.match(/\.(md|mdx|txt|yml|yaml)$/);
84
- if (ext) {
85
- const base = basename(docPath, ext[0]);
86
- const parent = basename(dirname(docPath));
87
- if (base && parent && base === parent) {
88
- // Folder-named file → also serves the folder's index URL
89
- const folderUrl = normalizeUrl('/' + dirname(docPath).replace(
90
- sourceDir.endsWith('/') ? sourceDir : sourceDir + '/', '') + '/');
91
- if (!urls.includes(folderUrl)) urls.push(folderUrl);
92
- }
93
- }
94
- return urls;
95
- }
96
-
97
- /**
98
- * Broadcast a message to all connected WebSocket clients.
99
- * @param {object} messageObj - Object to JSON.stringify and send
100
- */
101
- function broadcastMessage(messageObj) {
102
- if (!wss) return;
103
- const message = JSON.stringify(messageObj);
104
- wss.clients.forEach(client => {
105
- if (client.readyState === 1) client.send(message);
106
- });
107
- }
108
-
109
- /**
110
- * Send a message only to clients viewing a specific set of URLs.
111
- * Compares using normalizeUrl for consistent matching.
112
- * @param {object} messageObj - Object to send
113
- * @param {Set<string>} urls - Set of normalized URL paths to match against
114
- */
115
- function sendToClientsViewing(messageObj, urls) {
116
- if (!wss) return;
117
- const message = JSON.stringify(messageObj);
118
- for (const [client, clientUrl] of clientUrls) {
119
- if (client.readyState === 1 && clientUrl && urls.has(normalizeUrl(clientUrl))) {
120
- client.send(message);
121
- }
122
- }
123
- }
124
-
125
- /**
126
- * Broadcast a reload message to all connected clients
127
- * @param {string} [changedFile] - Optional path of the changed file
128
- */
129
- function broadcastReload(changedFile = null) {
130
- broadcastMessage({ type: 'reload', file: changedFile, timestamp: Date.now() });
131
- const clientCount = wss ? wss.clients.size : 0;
132
- if (clientCount > 0) {
133
- console.log(`🔄 Hot reload: notified ${clientCount} browser${clientCount > 1 ? 's' : ''}`);
134
- }
135
- }
136
-
137
- /**
138
- * Send reload only to clients viewing the given URL paths.
139
- * Other clients get 'update-no-affect' to clear their loading indicator.
140
- * @param {Set<string>} affectedUrls - URL paths that were regenerated
141
- * @param {string} [changedFile] - Source file that changed
142
- */
143
- function reloadAffectedClients(affectedUrls, changedFile = null) {
144
- if (!wss) return;
145
- let reloaded = 0;
146
- let cleared = 0;
147
- for (const [client, clientUrl] of clientUrls) {
148
- if (client.readyState !== 1) continue;
149
- const normalized = normalizeUrl(clientUrl);
150
- if (normalized && affectedUrls.has(normalized)) {
151
- client.send(JSON.stringify({ type: 'reload', file: changedFile, timestamp: Date.now() }));
152
- reloaded++;
153
- } else {
154
- client.send(JSON.stringify({ type: 'update-no-affect', timestamp: Date.now() }));
155
- cleared++;
156
- }
157
- }
158
- if (reloaded > 0) {
159
- console.log(`🔄 Hot reload: ${reloaded} affected client${reloaded > 1 ? 's' : ''} reloaded${cleared > 0 ? `, ${cleared} unaffected` : ''}`);
160
- }
161
- }
162
-
163
- /**
164
- * Generate the hot reload client script
165
- * @param {number} wsPort - WebSocket server port
166
- * @returns {string} JavaScript code to inject
167
- */
168
- function getHotReloadScript(wsPort) {
169
- return `
170
- <!-- Ursa Hot Reload -->
171
- <script>
172
- (function() {
173
- const wsUrl = 'ws://' + window.location.hostname + ':${wsPort}';
174
- let ws;
175
- let reconnectAttempts = 0;
176
- const maxReconnectAttempts = 10;
177
- const reconnectDelay = 1000;
178
-
179
- // Loading indicator management
180
- let indicatorEl = null;
181
- function getIndicator() {
182
- if (indicatorEl) return indicatorEl;
183
- indicatorEl = document.getElementById('ursa-update-indicator');
184
- return indicatorEl;
185
- }
186
- function showIndicator(color) {
187
- const el = getIndicator();
188
- if (!el) return;
189
- el.style.display = 'flex';
190
- el.className = 'ursa-update-indicator ursa-update-' + color;
191
- }
192
- function hideIndicator() {
193
- const el = getIndicator();
194
- if (!el) return;
195
- el.style.display = 'none';
196
- el.className = 'ursa-update-indicator';
197
- }
198
-
199
- function sendUrl() {
200
- if (ws && ws.readyState === 1) {
201
- ws.send(JSON.stringify({ type: 'url', url: window.location.pathname }));
202
- }
203
- }
204
-
205
- function connect() {
206
- ws = new WebSocket(wsUrl);
207
-
208
- ws.onopen = function() {
209
- console.log('[Ursa] Hot reload connected');
210
- reconnectAttempts = 0;
211
- sendUrl();
212
- };
213
-
214
- ws.onmessage = function(event) {
215
- try {
216
- const data = JSON.parse(event.data);
217
- switch (data.type) {
218
- case 'reload':
219
- hideIndicator();
220
- console.log('[Ursa] Reloading page...');
221
- window.location.reload();
222
- break;
223
- case 'update-start':
224
- showIndicator('gray');
225
- break;
226
- case 'update-affects-you':
227
- showIndicator('green');
228
- break;
229
- case 'update-no-affect':
230
- hideIndicator();
231
- break;
232
- }
233
- } catch (e) {
234
- console.error('[Ursa] Hot reload error:', e);
235
- }
236
- };
237
-
238
- ws.onclose = function() {
239
- if (reconnectAttempts < maxReconnectAttempts) {
240
- reconnectAttempts++;
241
- console.log('[Ursa] Hot reload disconnected, reconnecting... (' + reconnectAttempts + '/' + maxReconnectAttempts + ')');
242
- setTimeout(connect, reconnectDelay);
243
- } else {
244
- console.log('[Ursa] Hot reload: max reconnect attempts reached');
245
- }
246
- };
247
-
248
- ws.onerror = function(error) {
249
- console.error('[Ursa] Hot reload WebSocket error');
250
- };
251
- }
252
-
253
- connect();
254
-
255
- // Track navigation (SPA-style or hash changes)
256
- window.addEventListener('popstate', sendUrl);
257
- // Also re-send on page visibility change (e.g. tab switch)
258
- document.addEventListener('visibilitychange', function() {
259
- if (!document.hidden) sendUrl();
260
- });
261
- })();
262
- </script>
263
- `;
264
- }
265
-
266
- // Lock for preventing concurrent regenerations
267
- let isRegenerating = false;
268
-
269
- // Debounce state for file change batching
270
- const DEBOUNCE_MS = 500; // Wait 500ms of quiet before starting regeneration
271
- let pendingChanges = []; // { evt, name, watcher: 'source'|'meta' }
272
- let debounceTimer = null;
273
-
274
- /**
275
- * Copy a single CSS file to the output directory
276
- * @param {string} cssPath - Absolute path to the CSS file
277
- * @param {string} sourceDir - Source directory root
278
- * @param {string} outputDir - Output directory root
279
- */
280
- async function copyCssFile(cssPath, sourceDir, outputDir) {
281
- const startTime = Date.now();
282
- const relativePath = cssPath.replace(sourceDir, '');
283
- const outputPath = join(outputDir, relativePath);
284
-
285
- try {
286
- const content = await readFile(cssPath, 'utf8');
287
- await outputFile(outputPath, content);
288
- const elapsed = Date.now() - startTime;
289
- return { success: true, message: `Copied ${relativePath} in ${elapsed}ms` };
290
- } catch (e) {
291
- return { success: false, message: `Error copying CSS: ${e.message}` };
292
- }
293
- }
294
-
295
- // Static file extensions that should be copied (images, fonts, etc.)
296
- const STATIC_FILE_EXTENSIONS = /\.(jpg|jpeg|png|gif|webp|svg|ico|woff|woff2|ttf|eot|pdf|mp3|mp4|webm|ogg)$/i;
297
- // Image extensions that get preview processing
298
- const IMAGE_EXTENSIONS = /\.(jpg|jpeg|png|gif|webp|svg|ico)$/i;
299
-
300
- /**
301
- * Copy a single static file to the output directory
302
- * For images, also generates a preview version and updates the imageMap cache
303
- * @param {string} filePath - Absolute path to the static file
304
- * @param {string} sourceDir - Source directory root
305
- * @param {string} outputDir - Output directory root
306
- */
307
- async function copyStaticFile(filePath, sourceDir, outputDir) {
308
- const startTime = Date.now();
309
- const relativePath = filePath.replace(sourceDir, '');
310
- const relativeDir = dirname(relativePath);
311
- const absoluteOutputDir = join(outputDir, relativeDir);
312
-
313
- try {
314
- // Check if this is an image that needs preview processing
315
- if (IMAGE_EXTENSIONS.test(filePath)) {
316
- const result = await processImage(filePath, absoluteOutputDir, relativeDir);
317
- const elapsed = Date.now() - startTime;
318
-
319
- // Update the watchModeCache.imageMap so regenerated documents can use the new image
320
- if (result && watchModeCache.imageMap) {
321
- // The key is the absolute URL path (e.g., /campaigns/ABS/img/map.jpg)
322
- const imageKey = result.original;
323
- watchModeCache.imageMap.set(imageKey, result);
324
- }
325
-
326
- if (result && result.preview !== result.original) {
327
- return { success: true, message: `Processed ${relativePath} with preview in ${elapsed}ms` };
328
- }
329
- return { success: true, message: `Copied ${relativePath} in ${elapsed}ms` };
330
- }
331
-
332
- // For non-image files, just copy
333
- const outputPath = join(outputDir, relativePath);
334
- await mkdir(dirname(outputPath), { recursive: true });
335
- await copyFile(filePath, outputPath);
336
- const elapsed = Date.now() - startTime;
337
- return { success: true, message: `Copied ${relativePath} in ${elapsed}ms` };
338
- } catch (e) {
339
- return { success: false, message: `Error copying static file: ${e.message}` };
340
- }
341
- }
342
-
343
- /**
344
- * Configurable serve function for CLI and library use
345
- */
346
- export async function serve({
347
- _source,
348
- _meta,
349
- _output,
350
- port = 8080,
351
- _whitelist = null,
352
- _clean = false,
353
- _exclude = null
354
- } = {}) {
355
- const sourceDir = resolve(_source);
356
- const metaDir = resolve(_meta);
357
- const outputDir = resolve(_output);
358
-
359
- console.log({ source: sourceDir, meta: metaDir, output: outputDir, port, whitelist: _whitelist, exclude: _exclude, clean: _clean });
360
-
361
- // Resolve port (prompt user if occupied)
362
- port = await resolvePort(port);
363
-
364
- // Ensure output directory exists and start server immediately
365
- await mkdir(outputDir, { recursive: true });
366
- serveFiles(outputDir, port);
367
- console.log(`🚀 Development server running at http://localhost:${port}`);
368
- console.log("📁 Serving files from:", outputDir);
369
- console.log("⏳ Generating site in background (deferred image + search index processing)...\n");
370
-
371
- // Initial generation with deferred image and search index processing for faster startup
372
- // This also initializes the watch cache for fast single-file updates
373
- generate({ _source: sourceDir, _meta: metaDir, _output: outputDir, _whitelist, _exclude, _clean, _deferImages: true, _deferSearchIndex: true })
374
- .then(async (result) => {
375
- console.log("\n✅ Initial HTML generation complete. Fast single-file regeneration enabled.");
376
- console.log(" Note: Images/search may be incomplete until background processing completes.\n");
377
-
378
- // Wait for deferred processing to complete in parallel
379
- const promises = [];
380
-
381
- if (result && result.deferredImageProcessing) {
382
- promises.push(
383
- result.deferredImageProcessing
384
- .then(() => console.log("\n✅ Image preview generation complete."))
385
- .catch(error => console.error("Error during image processing:", error.message))
386
- );
387
- }
388
-
389
- if (result && result.deferredSearchIndex) {
390
- promises.push(
391
- result.deferredSearchIndex
392
- .then(() => console.log("✅ Search index generation complete."))
393
- .catch(error => console.error("Error during search index generation:", error.message))
394
- );
395
- }
396
-
397
- await Promise.all(promises);
398
- if (promises.length > 0) {
399
- console.log("\n✅ Full site ready.\n");
400
- }
401
- })
402
- .catch((error) => console.error("Error during initial generation:", error.message));
403
-
404
- // Watch for changes
405
- console.log("👀 Watching for changes in:");
406
- console.log(" Source:", sourceDir, "(fast single-file mode)");
407
- console.log(" Meta:", metaDir, "(full rebuild)");
408
- console.log("\nPress Ctrl+C to stop the server\n");
409
-
410
- /**
411
- * Queue a file change for debounced batch processing.
412
- * Sends 'update-start' to all clients on the first change in a batch.
413
- * Resets the 500ms debounce timer on each subsequent change.
414
- */
415
- function queueChange(evt, name, watcher) {
416
- // Send 'update-start' immediately on first change in a batch
417
- if (pendingChanges.length === 0) {
418
- broadcastMessage({ type: 'update-start', timestamp: Date.now() });
419
- }
420
- pendingChanges.push({ evt, name, watcher });
421
-
422
- // Reset debounce timer
423
- if (debounceTimer) clearTimeout(debounceTimer);
424
- debounceTimer = setTimeout(() => {
425
- debounceTimer = null;
426
- const batch = pendingChanges.splice(0);
427
- processChangeBatch(batch, sourceDir, metaDir, outputDir, _whitelist, _exclude);
428
- }, DEBOUNCE_MS);
429
- }
430
-
431
- /**
432
- * Process a batch of accumulated file changes.
433
- * Categorizes changes, handles immediate operations (copies), then
434
- * regenerates affected documents with priority ordering.
435
- */
436
- async function processChangeBatch(batch, sourceDir, metaDir, outputDir, _whitelist, _exclude) {
437
- if (isRegenerating) {
438
- console.log(`⏳ Debounce batch skipped (regeneration already in progress) — ${batch.length} changes lost`);
439
- broadcastMessage({ type: 'update-no-affect', timestamp: Date.now() });
440
- return;
441
- }
442
- isRegenerating = true;
443
-
444
- try {
445
- // Categorize changes
446
- const metaChanges = batch.filter(c => c.watcher === 'meta');
447
- const sourceChanges = batch.filter(c => c.watcher === 'source');
448
-
449
- const cssChanges = sourceChanges.filter(c => c.name?.endsWith('.css'));
450
- const scriptJsChanges = sourceChanges.filter(c => c.name && basename(c.name) === 'script.js');
451
- const staticChanges = sourceChanges.filter(c => c.name && STATIC_FILE_EXTENSIONS.test(c.name));
452
- const menuConfigChanges = sourceChanges.filter(c => {
453
- if (!c.name) return false;
454
- return c.name.includes('_menu') || c.name.includes('menu.') || c.name.includes('_config') || c.name.includes('.ursa');
455
- });
456
- const articleChanges = sourceChanges.filter(c => c.name && /\.(md|mdx|txt|yml)$/.test(c.name))
457
- .filter(c => !menuConfigChanges.some(m => m.name === c.name)); // exclude menu files already handled
458
- const otherSourceChanges = sourceChanges.filter(c =>
459
- !cssChanges.includes(c) && !scriptJsChanges.includes(c) && !staticChanges.includes(c) &&
460
- !menuConfigChanges.includes(c) && !articleChanges.includes(c)
461
- );
462
-
463
- const allNames = batch.map(c => c.name).filter(Boolean);
464
- const uniqueNames = [...new Set(allNames)];
465
- console.log(`\n📦 Processing batch: ${uniqueNames.length} file(s) changed`);
466
- for (const n of uniqueNames) console.log(` ${n}`);
467
-
468
- // Track whether we need a full rebuild (menu/config change, or unknown meta change)
469
- let needsFullRebuild = false;
470
- let fullRebuildReason = '';
471
- // Collect all document paths that need regeneration (for selective rebuild)
472
- const affectedDocPaths = new Set();
473
-
474
- // --- 1) Handle static file copies (immediate, no rebuild) ---
475
- for (const change of staticChanges) {
476
- const { evt, name } = change;
477
- if (evt === 'remove') {
478
- const relativePath = name.replace(sourceDir, '');
479
- const outputPath = join(outputDir, relativePath);
480
- try { await promises.unlink(outputPath); console.log(`🗑️ Removed static: ${relativePath}`); } catch {}
481
- } else {
482
- const result = await copyStaticFile(name, sourceDir + '/', outputDir + '/');
483
- if (result.success) console.log(`✅ ${result.message}`);
484
- }
485
- }
486
-
487
- // --- 2) Handle CSS copies + gather affected docs ---
488
- for (const change of cssChanges) {
489
- const result = await copyCssFile(change.name, sourceDir + '/', outputDir + '/');
490
- if (result.success) console.log(`✅ ${result.message}`);
491
- // Clear CSS bundle cache so affected documents will regenerate bundles
492
- clearStyleCache();
493
- if (watchModeCache.isInitialized) {
494
- const plan = dependencyTracker.getInvalidationPlan(change.name, sourceDir);
495
- if (plan.requiresFullRebuild) {
496
- needsFullRebuild = true;
497
- fullRebuildReason = plan.reason;
498
- } else {
499
- plan.affectedDocuments.forEach(d => affectedDocPaths.add(d));
500
- }
501
- }
502
- }
503
-
504
- // --- 3) Handle script.js copies + gather affected docs ---
505
- for (const change of scriptJsChanges) {
506
- const relativePath = change.name.replace(sourceDir + '/', '').replace(sourceDir, '');
507
- const outputPath = join(outputDir, relativePath);
508
- const content = await readFile(change.name, 'utf8');
509
- await outputFile(outputPath, content);
510
- console.log(`✅ Copied ${relativePath}`);
511
- // Clear script bundle cache so affected documents will regenerate bundles
512
- clearScriptCache();
513
- if (watchModeCache.isInitialized) {
514
- const plan = dependencyTracker.getInvalidationPlan(change.name, sourceDir);
515
- plan.affectedDocuments.forEach(d => affectedDocPaths.add(d));
516
- }
517
- }
518
-
519
- // --- 4) Handle meta changes ---
520
- if (metaChanges.length > 0) {
521
- console.log(`🎨 Processing ${metaChanges.length} meta change(s)`);
522
- const pub = join(outputDir, 'public');
523
- clearMetaBundleCache();
524
- await copyMetaAssets(metaDir, pub);
525
- const freshTemplates = await getTemplates(metaDir);
526
- const bundledTemplates = await bundleMetaTemplateAssets(freshTemplates, metaDir, pub, { minify: true, sourcemap: false });
527
- console.log('🔄 Reloaded and re-bundled meta templates');
528
-
529
- if (watchModeCache.isInitialized) {
530
- watchModeCache.templates = bundledTemplates;
531
- // Check each meta change for its invalidation plan
532
- for (const change of metaChanges) {
533
- const plan = dependencyTracker.getMetaInvalidationPlan(change.name, metaDir);
534
- if (plan.requiresFullRebuild) {
535
- needsFullRebuild = true;
536
- fullRebuildReason = plan.reason;
537
- } else {
538
- plan.affectedDocuments.forEach(d => affectedDocPaths.add(d));
539
- }
540
- }
541
- } else {
542
- needsFullRebuild = true;
543
- fullRebuildReason = 'Cache not initialized';
544
- }
545
- }
546
-
547
- // --- 5) Handle menu/config changes → force full rebuild ---
548
- if (menuConfigChanges.length > 0) {
549
- needsFullRebuild = true;
550
- fullRebuildReason = `Menu/config change: ${menuConfigChanges.map(c => basename(c.name)).join(', ')}`;
551
- // Delete on-disk caches to force full navigation + content rebuild
552
- const ursaDir = join(sourceDir, '.ursa');
553
- try { await promises.unlink(join(ursaDir, 'content-hashes.json')); } catch {}
554
- try { await promises.unlink(join(ursaDir, 'nav-cache.json')); } catch {}
555
- }
556
-
557
- // --- 5.5) Handle document template changes ---
558
- // If a _templates/*.md file changed, reconcile all documents using that template
559
- // and add the affected instance documents to the regeneration set.
560
- const templateChanges = articleChanges.filter(c => c.name && isInsideTemplatesFolder(c.name));
561
- if (templateChanges.length > 0 && watchModeCache.isInitialized) {
562
- const allArticles = watchModeCache.allArticlePaths || [];
563
- for (const change of templateChanges) {
564
- console.log(`📄 Document template changed: ${basename(change.name)}`);
565
- const reconcileResult = await reconcileByTemplate(change.name, allArticles, sourceDir);
566
- if (reconcileResult.updated > 0 || reconcileResult.conflicts > 0) {
567
- console.log(` ${reconcileResult.updated} auto-merged, ${reconcileResult.conflicts} conflicts`);
568
- reconcileResult.affectedPaths.forEach(p => affectedDocPaths.add(p));
569
- }
570
- if (reconcileResult.conflicts > 0) {
571
- for (const msg of reconcileResult.messages) {
572
- if (msg.includes('Conflict')) console.warn(` ⚠️ ${msg}`);
573
- }
574
- }
575
- }
576
- }
577
-
578
- // --- 6) Handle article changes via fast single-file regen ---
579
- // Deduplicate articles (same file may appear multiple times in rapid saves)
580
- // Exclude _templates files from direct article regeneration (they aren't rendered)
581
- const uniqueArticles = [...new Set(articleChanges.map(c => c.name))]
582
- .filter(name => !isInsideTemplatesFolder(name));
583
- for (const articlePath of uniqueArticles) {
584
- affectedDocPaths.add(articlePath);
585
- }
586
-
587
- // --- 7) Handle other source changes → full rebuild ---
588
- if (otherSourceChanges.length > 0) {
589
- needsFullRebuild = true;
590
- fullRebuildReason = `Non-standard source change: ${otherSourceChanges.map(c => basename(c.name || 'unknown')).join(', ')}`;
591
- }
592
-
593
- // --- 8) Execute rebuild ---
594
- if (needsFullRebuild) {
595
- console.log(`📦 Full rebuild required: ${fullRebuildReason}`);
596
- clearWatchCache();
597
- try {
598
- const result = await generate({ _source: sourceDir, _meta: metaDir, _output: outputDir, _whitelist, _exclude, _deferImages: true, _deferSearchIndex: true });
599
- console.log("HTML regeneration complete.");
600
- if (result?.deferredImageProcessing) {
601
- result.deferredImageProcessing.then(() => console.log("Image preview generation complete.")).catch(e => console.error("Image processing error:", e.message));
602
- }
603
- if (result?.deferredSearchIndex) {
604
- result.deferredSearchIndex.then(() => console.log("Search index generation complete.")).catch(e => console.error("Search index error:", e.message));
605
- }
606
- // Full rebuild: reload all clients
607
- broadcastReload(uniqueNames[0]);
608
- } catch (genError) {
609
- console.error(`❌ Full rebuild failed:`, genError);
610
- console.error(genError.stack);
611
- // Still reload — fresh content may be partially written, better than stale
612
- broadcastReload(uniqueNames[0]);
613
- }
614
- } else if (affectedDocPaths.size > 0) {
615
- // Selective rebuild with priority ordering
616
- const docPathsArray = [...affectedDocPaths];
617
-
618
- // Determine which URLs clients are viewing, map to source paths for priority
619
- const viewedUrls = getClientViewedUrls().map(normalizeUrl);
620
- const priorityPaths = [];
621
- const affectedUrlSet = new Set();
622
-
623
- for (const docPath of docPathsArray) {
624
- const urls = docPathToUrls(docPath, sourceDir + '/');
625
- for (const url of urls) {
626
- affectedUrlSet.add(url);
627
- if (viewedUrls.includes(url) && !priorityPaths.includes(docPath)) {
628
- priorityPaths.push(docPath);
629
- }
630
- }
631
- }
632
-
633
- console.log(`🔀 Selective rebuild: ${docPathsArray.length} docs, ${priorityPaths.length} priority`);
634
- if (priorityPaths.length > 0) {
635
- console.log(` Priority: ${priorityPaths.map(p => basename(p)).join(', ')}`);
636
- }
637
- console.log(` Client URLs: ${viewedUrls.join(', ') || '(none)'}`);
638
- console.log(` Affected URLs: ${[...affectedUrlSet].slice(0, 5).join(', ')}${affectedUrlSet.size > 5 ? ` +${affectedUrlSet.size - 5} more` : ''}`);
639
-
640
- // Notify clients whether the change affects them
641
- if (affectedUrlSet.size > 0) {
642
- sendToClientsViewing({ type: 'update-affects-you', timestamp: Date.now() }, affectedUrlSet);
643
- }
644
-
645
- const regenResult = await regenerateAffectedDocuments(docPathsArray, {
646
- _source: sourceDir, _meta: metaDir, _output: outputDir,
647
- reason: `batch: ${uniqueNames.map(n => basename(n)).join(', ')}`,
648
- priorityPaths,
649
- onPriorityComplete: ({ regenerated, failed, priorityDocs }) => {
650
- if (regenerated > 0) {
651
- // Immediately reload clients whose pages are now ready
652
- const readyUrls = new Set(priorityDocs.flatMap(p => docPathToUrls(p, sourceDir + '/')));
653
- console.log(`⚡ Priority complete: ${regenerated} OK, ${failed} failed → reloading clients`);
654
- reloadAffectedClients(readyUrls, uniqueNames[0]);
655
- } else if (failed > 0) {
656
- console.warn(`⚠️ Priority regen failed for all ${failed} docs — not reloading yet`);
657
- }
658
- },
659
- });
660
-
661
- // After all remaining docs are done, reload any remaining affected clients
662
- // (non-priority clients that weren't reloaded during onPriorityComplete)
663
- const priorityUrlSet = new Set(priorityPaths.flatMap(p => docPathToUrls(p, sourceDir + '/')));
664
- const remainingUrls = new Set([...affectedUrlSet].filter(u => !priorityUrlSet.has(u)));
665
- if (remainingUrls.size > 0) {
666
- reloadAffectedClients(remainingUrls, uniqueNames[0]);
667
- }
668
-
669
- // If priority docs all failed, try reloading anyway now that remaining are done
670
- if (priorityPaths.length > 0 && regenResult.regenerated > 0) {
671
- const failedPriorityUrls = new Set();
672
- // Check if any priority was among the failed — reload all affected as fallback
673
- for (const pp of priorityPaths) {
674
- failedPriorityUrls.add(docPathToUrl(pp, sourceDir + '/'));
675
- }
676
- // If regeneration succeeded overall, make sure all priority clients got reloaded
677
- for (const [client, clientUrl] of clientUrls) {
678
- if (client.readyState === 1 && clientUrl && failedPriorityUrls.has(normalizeUrl(clientUrl))) {
679
- // Client might not have been reloaded if their specific doc failed but others succeeded
680
- // The onPriorityComplete callback should have handled this, this is a safety net
681
- }
682
- }
683
- }
684
-
685
- // Clear indicator for clients not affected at all
686
- for (const [client, clientUrl] of clientUrls) {
687
- if (client.readyState === 1 && clientUrl && !affectedUrlSet.has(normalizeUrl(clientUrl))) {
688
- client.send(JSON.stringify({ type: 'update-no-affect', timestamp: Date.now() }));
689
- }
690
- }
691
- } else {
692
- // No documents affected (e.g. static-only changes) — reload all clients
693
- if (staticChanges.length > 0) {
694
- broadcastReload(uniqueNames[0]);
695
- } else {
696
- // Nothing to do — clear indicators
697
- broadcastMessage({ type: 'update-no-affect', timestamp: Date.now() });
698
- }
699
- }
700
- } catch (error) {
701
- console.error(`❌ Error during batch processing:`, error);
702
- console.error(error.stack);
703
- // Reload clients as fallback — stale content with a reload is better than a stuck spinner
704
- broadcastReload();
705
- } finally {
706
- isRegenerating = false;
707
- }
708
- }
709
-
710
- // Meta changes: queue for debounced batch processing
711
- watch(metaDir, { recursive: true, filter: /\.(js|json|css|html|md|txt|yml|yaml)$/ }, (evt, name) => {
712
- queueChange(evt, name, 'meta');
713
- });
714
-
715
- // Source changes: queue for debounced batch processing
716
- watch(sourceDir, {
717
- recursive: true,
718
- filter: (f, skip) => {
719
- // Skip .ursa folder (contains hash cache that gets updated during generation)
720
- if (/[\/\\]\.ursa[\/\\]?/.test(f)) return skip;
721
- // Watch article files, config files, and static assets
722
- return /\.(js|json|css|html|md|mdx|txt|yml|yaml|tsx|ts|jsx|jpg|jpeg|png|gif|webp|svg|ico|woff|woff2|ttf|eot|pdf|mp3|mp4|webm|ogg)$/i.test(f);
723
- }
724
- }, (evt, name) => {
725
- queueChange(evt, name, 'source');
726
- });
727
- }
728
-
729
- /**
730
- * Start HTTP server to serve static files with hot reload support
731
- * @param {string} outputDir - Directory to serve files from
732
- * @param {number} port - HTTP server port
733
- * @returns {object} Object containing httpServer and wsPort
734
- */
735
- function serveFiles(outputDir, port = 8080) {
736
- const app = express();
737
- const wsPort = port + 1; // WebSocket on port+1
738
-
739
- // Enable gzip compression for all responses
740
- // This significantly reduces transfer size for JSON and HTML files
741
- app.use(compression({
742
- // Compress everything over 1KB
743
- threshold: 1024,
744
- // Use default compression level (good balance of speed vs size)
745
- level: 6
746
- }));
747
-
748
- // Middleware to inject hot reload script into HTML responses
749
- app.use(async (req, res, next) => {
750
- // Only intercept HTML requests
751
- const url = req.url;
752
- const isHtmlRequest = url.endsWith('.html') ||
753
- url.endsWith('/') ||
754
- !url.includes('.') ||
755
- url === '/';
756
-
757
- if (!isHtmlRequest) {
758
- return next();
759
- }
760
-
761
- // Determine the file path
762
- let filePath;
763
- if (url === '/' || url.endsWith('/')) {
764
- filePath = join(outputDir, url, 'index.html');
765
- } else if (url.endsWith('.html')) {
766
- filePath = join(outputDir, url);
767
- } else {
768
- // Try adding .html extension
769
- filePath = join(outputDir, url + '.html');
770
- if (!fs.existsSync(filePath)) {
771
- filePath = join(outputDir, url, 'index.html');
772
- }
773
- }
774
-
775
- try {
776
- if (fs.existsSync(filePath)) {
777
- let html = await readFile(filePath, 'utf8');
778
- // Inject hot reload script before </body>
779
- const hotReloadScript = getHotReloadScript(wsPort);
780
- if (html.includes('</body>')) {
781
- html = html.replace('</body>', hotReloadScript + '</body>');
782
- } else {
783
- html += hotReloadScript;
784
- }
785
- res.setHeader('Content-Type', 'text/html');
786
- res.send(html);
787
- } else {
788
- next();
789
- }
790
- } catch (error) {
791
- next();
792
- }
793
- });
794
-
795
- // Fallback static file serving for non-HTML files
796
- app.use(
797
- express.static(outputDir, { extensions: ["html"], index: "index.html" })
798
- );
799
-
800
- // Create HTTP server
801
- const httpServer = createServer(app);
802
-
803
- // Create WebSocket server for hot reload
804
- wss = new WebSocketServer({ port: wsPort });
805
-
806
- wss.on('connection', (ws) => {
807
- // Send a ping to keep connection alive
808
- const pingInterval = setInterval(() => {
809
- if (ws.readyState === 1) {
810
- ws.ping();
811
- }
812
- }, 30000);
813
-
814
- // Handle messages from the client (URL tracking)
815
- ws.on('message', (data) => {
816
- try {
817
- const msg = JSON.parse(data.toString());
818
- if (msg.type === 'url' && msg.url) {
819
- clientUrls.set(ws, msg.url);
820
- }
821
- } catch (e) { /* ignore non-JSON messages */ }
822
- });
823
-
824
- ws.on('close', () => {
825
- clearInterval(pingInterval);
826
- clientUrls.delete(ws);
827
- });
828
- });
829
-
830
- httpServer.listen(port, () => {
831
- console.log(`🌐 Server listening on port ${port}`);
832
- console.log(`🔥 Hot reload WebSocket on port ${wsPort}`);
833
- });
834
-
835
- return { httpServer, wsPort };
836
- }
837
-
838
- /**
839
- * we're only interested in meta (and maybe, in the future, source)
840
- * for src changes, we need the node process to restart
841
- */
842
- function filter(filename, skip) {
843
- // console.log("testing ", filename);
844
- if (/\/build/.test(filename)) return skip;
845
- if (/\/node_modules/.test(filename)) return skip;
846
- if (/\.git/.test(filename)) return skip;
847
- if (/\/src/.test(filename)) return skip;
848
- if (/\/meta/.test(filename)) return true;
849
- return false;
850
- }
851
-
852
- // Default serve function for backward compatibility (only run when executed directly)
853
- if (import.meta.url === `file://${process.argv[1]}`) {
854
- const source = resolve(process.env.SOURCE ?? join(process.cwd(), "source"));
855
- const meta = resolve(process.env.META ?? join(process.cwd(), "meta"));
856
- const output = resolve(process.env.OUTPUT ?? join(process.cwd(), "build"));
857
-
858
- console.log({ source, meta, output });
859
-
860
- await generate({ _source: source, _meta: meta, _output: output });
861
- console.log("done generating. now serving...");
862
-
863
- serveFiles(output);
864
-
865
- watch(meta, { recursive: true }, async (evt, name) => {
866
- console.log("meta files changed! generating output");
867
- await generate({ _source: source, _meta: meta, _output: output });
868
- });
869
-
870
- watch(source, { recursive: true }, async (evt, name) => {
871
- console.log("source files changed! generating output");
872
- await generate({ _source: source, _meta: meta, _output: output });
873
- });
874
- }
1
+ import express from "express";
2
+ import compression from "compression";
3
+ import watch from "node-watch";
4
+ import { generate, regenerateAffectedDocuments, clearWatchCache, clearScriptCache, clearStyleCache } from "./jobs/generate.js";
5
+ import { join, resolve, dirname, basename } from "path";
6
+ import fs from "fs";
7
+ import { promises } from "fs";
8
+ import { copy as copyDir, outputFile } from "fs-extra";
9
+ import { processImage } from "./helper/imageProcessor.js";
10
+ import { watchModeCache } from "./helper/build/watchCache.js";
11
+ import { dependencyTracker } from "./helper/dependencyTracker.js";
12
+ import { bundleMetaTemplateAssets, clearMetaBundleCache } from "./helper/assetBundler.js";
13
+ import { getTemplates, copyMetaAssets } from "./helper/build/templates.js";
14
+ import { isInsideTemplatesFolder, reconcileByTemplate } from "./helper/documentTemplates.js";
15
+ import { WebSocketServer } from "ws";
16
+ import { createServer } from "http";
17
+ import { resolvePort } from "./helper/portUtils.js";
18
+ const { readdir, mkdir, readFile, copyFile } = promises;
19
+
20
+ // WebSocket server for hot reloading
21
+ let wss = null;
22
+
23
+ /**
24
+ * Map of WebSocket client → current page URL path (e.g. '/campaigns/abs/index.html')
25
+ * Updated when clients send { type: 'url', url: '...' } messages.
26
+ */
27
+ const clientUrls = new Map();
28
+
29
+ /**
30
+ * Get URL paths that connected WebSocket clients are currently viewing.
31
+ * @returns {string[]} Array of unique URL paths
32
+ */
33
+ function getClientViewedUrls() {
34
+ const urls = new Set();
35
+ for (const [client, url] of clientUrls) {
36
+ if (client.readyState === 1 && url) urls.add(url);
37
+ }
38
+ return [...urls];
39
+ }
40
+
41
+ /**
42
+ * Normalize a URL path for comparison.
43
+ * Converts /path/index.html → /path/, /path.html → /path.html
44
+ * Strips trailing whitespace. Ensures leading /.
45
+ * @param {string} url
46
+ * @returns {string}
47
+ */
48
+ function normalizeUrl(url) {
49
+ if (!url) return '/';
50
+ let u = url.trim();
51
+ if (!u.startsWith('/')) u = '/' + u;
52
+ // /foo/index.html → /foo/
53
+ if (u.endsWith('/index.html')) u = u.slice(0, -10);
54
+ // Ensure trailing slash for directory-like paths (no extension)
55
+ if (!u.includes('.') && !u.endsWith('/')) u = u + '/';
56
+ return u;
57
+ }
58
+
59
+ /**
60
+ * Convert a source file path to the URL path it would produce,
61
+ * normalized for comparison with client URLs.
62
+ * e.g. /Users/.../docs/campaigns/abs/index.mdx → /campaigns/abs/
63
+ * @param {string} docPath - Absolute source path
64
+ * @param {string} sourceDir - Absolute source directory (with trailing slash)
65
+ * @returns {string} Normalized URL path
66
+ */
67
+ function docPathToUrl(docPath, sourceDir) {
68
+ const normalizedSource = sourceDir.endsWith('/') ? sourceDir : sourceDir + '/';
69
+ const rawUrl = '/' + docPath.replace(normalizedSource, '').replace(/\.(md|mdx|txt|yml|yaml)$/, '.html');
70
+ return normalizeUrl(rawUrl);
71
+ }
72
+
73
+ /**
74
+ * Return all URL paths a source file maps to. Most files map to a single URL,
75
+ * but folder-named files (e.g. aletheia/aletheia.md) are promoted to
76
+ * <folder>/index.html during the build, so they also serve the folder URL.
77
+ * @param {string} docPath - Absolute source path
78
+ * @param {string} sourceDir - Absolute source directory (with trailing slash)
79
+ * @returns {string[]} Normalized URL paths
80
+ */
81
+ function docPathToUrls(docPath, sourceDir) {
82
+ const urls = [docPathToUrl(docPath, sourceDir)];
83
+ const ext = docPath.match(/\.(md|mdx|txt|yml|yaml)$/);
84
+ if (ext) {
85
+ const base = basename(docPath, ext[0]);
86
+ const parent = basename(dirname(docPath));
87
+ if (base && parent && base === parent) {
88
+ // Folder-named file → also serves the folder's index URL
89
+ const folderUrl = normalizeUrl('/' + dirname(docPath).replace(
90
+ sourceDir.endsWith('/') ? sourceDir : sourceDir + '/', '') + '/');
91
+ if (!urls.includes(folderUrl)) urls.push(folderUrl);
92
+ }
93
+ }
94
+ return urls;
95
+ }
96
+
97
+ /**
98
+ * Broadcast a message to all connected WebSocket clients.
99
+ * @param {object} messageObj - Object to JSON.stringify and send
100
+ */
101
+ function broadcastMessage(messageObj) {
102
+ if (!wss) return;
103
+ const message = JSON.stringify(messageObj);
104
+ wss.clients.forEach(client => {
105
+ if (client.readyState === 1) client.send(message);
106
+ });
107
+ }
108
+
109
+ /**
110
+ * Send a message only to clients viewing a specific set of URLs.
111
+ * Compares using normalizeUrl for consistent matching.
112
+ * @param {object} messageObj - Object to send
113
+ * @param {Set<string>} urls - Set of normalized URL paths to match against
114
+ */
115
+ function sendToClientsViewing(messageObj, urls) {
116
+ if (!wss) return;
117
+ const message = JSON.stringify(messageObj);
118
+ for (const [client, clientUrl] of clientUrls) {
119
+ if (client.readyState === 1 && clientUrl && urls.has(normalizeUrl(clientUrl))) {
120
+ client.send(message);
121
+ }
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Broadcast a reload message to all connected clients
127
+ * @param {string} [changedFile] - Optional path of the changed file
128
+ */
129
+ function broadcastReload(changedFile = null) {
130
+ broadcastMessage({ type: 'reload', file: changedFile, timestamp: Date.now() });
131
+ const clientCount = wss ? wss.clients.size : 0;
132
+ if (clientCount > 0) {
133
+ console.log(`🔄 Hot reload: notified ${clientCount} browser${clientCount > 1 ? 's' : ''}`);
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Send reload only to clients viewing the given URL paths.
139
+ * Other clients get 'update-no-affect' to clear their loading indicator.
140
+ * @param {Set<string>} affectedUrls - URL paths that were regenerated
141
+ * @param {string} [changedFile] - Source file that changed
142
+ */
143
+ function reloadAffectedClients(affectedUrls, changedFile = null) {
144
+ if (!wss) return;
145
+ let reloaded = 0;
146
+ let cleared = 0;
147
+ for (const [client, clientUrl] of clientUrls) {
148
+ if (client.readyState !== 1) continue;
149
+ const normalized = normalizeUrl(clientUrl);
150
+ if (normalized && affectedUrls.has(normalized)) {
151
+ client.send(JSON.stringify({ type: 'reload', file: changedFile, timestamp: Date.now() }));
152
+ reloaded++;
153
+ } else {
154
+ client.send(JSON.stringify({ type: 'update-no-affect', timestamp: Date.now() }));
155
+ cleared++;
156
+ }
157
+ }
158
+ if (reloaded > 0) {
159
+ console.log(`🔄 Hot reload: ${reloaded} affected client${reloaded > 1 ? 's' : ''} reloaded${cleared > 0 ? `, ${cleared} unaffected` : ''}`);
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Generate the hot reload client script
165
+ * @param {number} wsPort - WebSocket server port
166
+ * @returns {string} JavaScript code to inject
167
+ */
168
+ function getHotReloadScript(wsPort) {
169
+ return `
170
+ <!-- Ursa Hot Reload -->
171
+ <script>
172
+ (function() {
173
+ const wsUrl = 'ws://' + window.location.hostname + ':${wsPort}';
174
+ let ws;
175
+ let reconnectAttempts = 0;
176
+ const maxReconnectAttempts = 10;
177
+ const reconnectDelay = 1000;
178
+
179
+ // Loading indicator management
180
+ let indicatorEl = null;
181
+ function getIndicator() {
182
+ if (indicatorEl) return indicatorEl;
183
+ indicatorEl = document.getElementById('ursa-update-indicator');
184
+ return indicatorEl;
185
+ }
186
+ function showIndicator(color) {
187
+ const el = getIndicator();
188
+ if (!el) return;
189
+ el.style.display = 'flex';
190
+ el.className = 'ursa-update-indicator ursa-update-' + color;
191
+ }
192
+ function hideIndicator() {
193
+ const el = getIndicator();
194
+ if (!el) return;
195
+ el.style.display = 'none';
196
+ el.className = 'ursa-update-indicator';
197
+ }
198
+
199
+ function sendUrl() {
200
+ if (ws && ws.readyState === 1) {
201
+ ws.send(JSON.stringify({ type: 'url', url: window.location.pathname }));
202
+ }
203
+ }
204
+
205
+ function connect() {
206
+ ws = new WebSocket(wsUrl);
207
+
208
+ ws.onopen = function() {
209
+ console.log('[Ursa] Hot reload connected');
210
+ reconnectAttempts = 0;
211
+ sendUrl();
212
+ };
213
+
214
+ ws.onmessage = function(event) {
215
+ try {
216
+ const data = JSON.parse(event.data);
217
+ switch (data.type) {
218
+ case 'reload':
219
+ hideIndicator();
220
+ console.log('[Ursa] Reloading page...');
221
+ window.location.reload();
222
+ break;
223
+ case 'update-start':
224
+ showIndicator('gray');
225
+ break;
226
+ case 'update-affects-you':
227
+ showIndicator('green');
228
+ break;
229
+ case 'update-no-affect':
230
+ hideIndicator();
231
+ break;
232
+ }
233
+ } catch (e) {
234
+ console.error('[Ursa] Hot reload error:', e);
235
+ }
236
+ };
237
+
238
+ ws.onclose = function() {
239
+ if (reconnectAttempts < maxReconnectAttempts) {
240
+ reconnectAttempts++;
241
+ console.log('[Ursa] Hot reload disconnected, reconnecting... (' + reconnectAttempts + '/' + maxReconnectAttempts + ')');
242
+ setTimeout(connect, reconnectDelay);
243
+ } else {
244
+ console.log('[Ursa] Hot reload: max reconnect attempts reached');
245
+ }
246
+ };
247
+
248
+ ws.onerror = function(error) {
249
+ console.error('[Ursa] Hot reload WebSocket error');
250
+ };
251
+ }
252
+
253
+ connect();
254
+
255
+ // Track navigation (SPA-style or hash changes)
256
+ window.addEventListener('popstate', sendUrl);
257
+ // Also re-send on page visibility change (e.g. tab switch)
258
+ document.addEventListener('visibilitychange', function() {
259
+ if (!document.hidden) sendUrl();
260
+ });
261
+ })();
262
+ </script>
263
+ `;
264
+ }
265
+
266
+ // Lock for preventing concurrent regenerations
267
+ let isRegenerating = false;
268
+
269
+ // Debounce state for file change batching
270
+ const DEBOUNCE_MS = 500; // Wait 500ms of quiet before starting regeneration
271
+ let pendingChanges = []; // { evt, name, watcher: 'source'|'meta' }
272
+ let debounceTimer = null;
273
+
274
+ // Changes that arrived while a regeneration pass was in flight.
275
+ // They are processed as the next batch when the current pass finishes —
276
+ // never dropped (passes run sequentially, single-writer).
277
+ let queuedDuringRegeneration = [];
278
+
279
+ /**
280
+ * Copy a single CSS file to the output directory
281
+ * @param {string} cssPath - Absolute path to the CSS file
282
+ * @param {string} sourceDir - Source directory root
283
+ * @param {string} outputDir - Output directory root
284
+ */
285
+ async function copyCssFile(cssPath, sourceDir, outputDir) {
286
+ const startTime = Date.now();
287
+ const relativePath = cssPath.replace(sourceDir, '');
288
+ const outputPath = join(outputDir, relativePath);
289
+
290
+ try {
291
+ const content = await readFile(cssPath, 'utf8');
292
+ await outputFile(outputPath, content);
293
+ const elapsed = Date.now() - startTime;
294
+ return { success: true, message: `Copied ${relativePath} in ${elapsed}ms` };
295
+ } catch (e) {
296
+ return { success: false, message: `Error copying CSS: ${e.message}` };
297
+ }
298
+ }
299
+
300
+ // Static file extensions that should be copied (images, fonts, etc.)
301
+ const STATIC_FILE_EXTENSIONS = /\.(jpg|jpeg|png|gif|webp|svg|ico|woff|woff2|ttf|eot|pdf|mp3|mp4|webm|ogg)$/i;
302
+ // Image extensions that get preview processing
303
+ const IMAGE_EXTENSIONS = /\.(jpg|jpeg|png|gif|webp|svg|ico)$/i;
304
+
305
+ /**
306
+ * Copy a single static file to the output directory
307
+ * For images, also generates a preview version and updates the imageMap cache
308
+ * @param {string} filePath - Absolute path to the static file
309
+ * @param {string} sourceDir - Source directory root
310
+ * @param {string} outputDir - Output directory root
311
+ */
312
+ async function copyStaticFile(filePath, sourceDir, outputDir) {
313
+ const startTime = Date.now();
314
+ const relativePath = filePath.replace(sourceDir, '');
315
+ const relativeDir = dirname(relativePath);
316
+ const absoluteOutputDir = join(outputDir, relativeDir);
317
+
318
+ try {
319
+ // Check if this is an image that needs preview processing
320
+ if (IMAGE_EXTENSIONS.test(filePath)) {
321
+ const result = await processImage(filePath, absoluteOutputDir, relativeDir);
322
+ const elapsed = Date.now() - startTime;
323
+
324
+ // Update the watchModeCache.imageMap so regenerated documents can use the new image
325
+ if (result && watchModeCache.imageMap) {
326
+ // The key is the absolute URL path (e.g., /campaigns/ABS/img/map.jpg)
327
+ const imageKey = result.original;
328
+ watchModeCache.imageMap.set(imageKey, result);
329
+ }
330
+
331
+ if (result && result.preview !== result.original) {
332
+ return { success: true, message: `Processed ${relativePath} with preview in ${elapsed}ms` };
333
+ }
334
+ return { success: true, message: `Copied ${relativePath} in ${elapsed}ms` };
335
+ }
336
+
337
+ // For non-image files, just copy
338
+ const outputPath = join(outputDir, relativePath);
339
+ await mkdir(dirname(outputPath), { recursive: true });
340
+ await copyFile(filePath, outputPath);
341
+ const elapsed = Date.now() - startTime;
342
+ return { success: true, message: `Copied ${relativePath} in ${elapsed}ms` };
343
+ } catch (e) {
344
+ return { success: false, message: `Error copying static file: ${e.message}` };
345
+ }
346
+ }
347
+
348
+ /**
349
+ * Configurable serve function for CLI and library use
350
+ */
351
+ export async function serve({
352
+ _source,
353
+ _meta,
354
+ _output,
355
+ port = 8080,
356
+ _whitelist = null,
357
+ _clean = false,
358
+ _exclude = null,
359
+ strictPort = false
360
+ } = {}) {
361
+ const sourceDir = resolve(_source);
362
+ const metaDir = resolve(_meta);
363
+ const outputDir = resolve(_output);
364
+
365
+ console.log({ source: sourceDir, meta: metaDir, output: outputDir, port, whitelist: _whitelist, exclude: _exclude, clean: _clean });
366
+
367
+ // Resolve port (prompt user if occupied)
368
+ port = await resolvePort(port, { strict: strictPort });
369
+
370
+ // Ensure output directory exists and start server immediately
371
+ await mkdir(outputDir, { recursive: true });
372
+ serveFiles(outputDir, port);
373
+ console.log(`🚀 Development server running at http://localhost:${port}`);
374
+ console.log("📁 Serving files from:", outputDir);
375
+ console.log("⏳ Generating site in background (deferred image + search index processing)...\n");
376
+
377
+ // Initial generation with deferred image and search index processing for faster startup
378
+ // This also initializes the watch cache for fast single-file updates
379
+ generate({ _source: sourceDir, _meta: metaDir, _output: outputDir, _whitelist, _exclude, _clean, _deferImages: true, _deferSearchIndex: true })
380
+ .then(async (result) => {
381
+ console.log("\n✅ Initial HTML generation complete. Fast single-file regeneration enabled.");
382
+ console.log(" Note: Images/search may be incomplete until background processing completes.\n");
383
+
384
+ // Wait for deferred processing to complete in parallel
385
+ const promises = [];
386
+
387
+ if (result && result.deferredImageProcessing) {
388
+ promises.push(
389
+ result.deferredImageProcessing
390
+ .then(() => console.log("\n✅ Image preview generation complete."))
391
+ .catch(error => console.error("Error during image processing:", error.message))
392
+ );
393
+ }
394
+
395
+ if (result && result.deferredSearchIndex) {
396
+ promises.push(
397
+ result.deferredSearchIndex
398
+ .then(() => console.log("✅ Search index generation complete."))
399
+ .catch(error => console.error("Error during search index generation:", error.message))
400
+ );
401
+ }
402
+
403
+ await Promise.all(promises);
404
+ if (promises.length > 0) {
405
+ console.log("\n✅ Full site ready.\n");
406
+ }
407
+ })
408
+ .catch((error) => console.error("Error during initial generation:", error.message));
409
+
410
+ // Watch for changes
411
+ console.log("👀 Watching for changes in:");
412
+ console.log(" Source:", sourceDir, "(fast single-file mode)");
413
+ console.log(" Meta:", metaDir, "(full rebuild)");
414
+ console.log("\nPress Ctrl+C to stop the server\n");
415
+
416
+ /**
417
+ * Queue a file change for debounced batch processing.
418
+ * Sends 'update-start' to all clients on the first change in a batch.
419
+ * Resets the 500ms debounce timer on each subsequent change.
420
+ */
421
+ function queueChange(evt, name, watcher) {
422
+ // Send 'update-start' immediately on first change in a batch
423
+ if (pendingChanges.length === 0) {
424
+ broadcastMessage({ type: 'update-start', timestamp: Date.now() });
425
+ }
426
+ pendingChanges.push({ evt, name, watcher });
427
+
428
+ // Reset debounce timer
429
+ if (debounceTimer) clearTimeout(debounceTimer);
430
+ debounceTimer = setTimeout(() => {
431
+ debounceTimer = null;
432
+ const batch = pendingChanges.splice(0);
433
+ processChangeBatch(batch, sourceDir, metaDir, outputDir, _whitelist, _exclude);
434
+ }, DEBOUNCE_MS);
435
+ }
436
+
437
+ /**
438
+ * Process a batch of accumulated file changes.
439
+ * Categorizes changes, handles immediate operations (copies), then
440
+ * regenerates affected documents with priority ordering.
441
+ */
442
+ async function processChangeBatch(batch, sourceDir, metaDir, outputDir, _whitelist, _exclude) {
443
+ if (isRegenerating) {
444
+ // Never drop changes: accumulate them and process when the current
445
+ // pass finishes (clients keep their update-start indicator until then)
446
+ queuedDuringRegeneration.push(...batch);
447
+ console.log(`⏳ Regeneration in progress — queued ${batch.length} change(s) for the next pass`);
448
+ return;
449
+ }
450
+ isRegenerating = true;
451
+
452
+ try {
453
+ // Categorize changes
454
+ const metaChanges = batch.filter(c => c.watcher === 'meta');
455
+ const metaStaticChanges = metaChanges.filter(c => c.name && STATIC_FILE_EXTENSIONS.test(c.name));
456
+ const sourceChanges = batch.filter(c => c.watcher === 'source');
457
+
458
+ const cssChanges = sourceChanges.filter(c => c.name?.endsWith('.css'));
459
+ const scriptJsChanges = sourceChanges.filter(c => c.name && basename(c.name) === 'script.js');
460
+ const staticChanges = sourceChanges.filter(c => c.name && STATIC_FILE_EXTENSIONS.test(c.name));
461
+ const menuConfigChanges = sourceChanges.filter(c => {
462
+ if (!c.name) return false;
463
+ return c.name.includes('_menu') || c.name.includes('menu.') || c.name.includes('_config') || c.name.includes('.ursa');
464
+ });
465
+ const articleChanges = sourceChanges.filter(c => c.name && /\.(md|mdx|txt|yml)$/.test(c.name))
466
+ .filter(c => !menuConfigChanges.some(m => m.name === c.name)); // exclude menu files already handled
467
+ const otherSourceChanges = sourceChanges.filter(c =>
468
+ !cssChanges.includes(c) && !scriptJsChanges.includes(c) && !staticChanges.includes(c) &&
469
+ !menuConfigChanges.includes(c) && !articleChanges.includes(c)
470
+ );
471
+
472
+ const allNames = batch.map(c => c.name).filter(Boolean);
473
+ const uniqueNames = [...new Set(allNames)];
474
+ console.log(`\n📦 Processing batch: ${uniqueNames.length} file(s) changed`);
475
+ for (const n of uniqueNames) console.log(` ${n}`);
476
+
477
+ // Track whether we need a full rebuild (menu/config change, or unknown meta change)
478
+ let needsFullRebuild = false;
479
+ let fullRebuildReason = '';
480
+ // Collect all document paths that need regeneration (for selective rebuild)
481
+ const affectedDocPaths = new Set();
482
+
483
+ // --- 1) Handle static file copies (immediate, no rebuild) ---
484
+ for (const change of staticChanges) {
485
+ const { evt, name } = change;
486
+ if (evt === 'remove') {
487
+ const relativePath = name.replace(sourceDir, '');
488
+ const outputPath = join(outputDir, relativePath);
489
+ try { await promises.unlink(outputPath); console.log(`🗑️ Removed static: ${relativePath}`); } catch {}
490
+ } else {
491
+ const result = await copyStaticFile(name, sourceDir + '/', outputDir + '/');
492
+ if (result.success) console.log(`✅ ${result.message}`);
493
+ }
494
+ }
495
+
496
+ // --- 2) Handle CSS copies + gather affected docs ---
497
+ for (const change of cssChanges) {
498
+ const result = await copyCssFile(change.name, sourceDir + '/', outputDir + '/');
499
+ if (result.success) console.log(`✅ ${result.message}`);
500
+ // Clear CSS bundle cache so affected documents will regenerate bundles
501
+ clearStyleCache();
502
+ if (watchModeCache.isInitialized) {
503
+ const plan = dependencyTracker.getInvalidationPlan(change.name, sourceDir);
504
+ if (plan.requiresFullRebuild) {
505
+ needsFullRebuild = true;
506
+ fullRebuildReason = plan.reason;
507
+ } else {
508
+ plan.affectedDocuments.forEach(d => affectedDocPaths.add(d));
509
+ }
510
+ }
511
+ }
512
+
513
+ // --- 3) Handle script.js copies + gather affected docs ---
514
+ for (const change of scriptJsChanges) {
515
+ const relativePath = change.name.replace(sourceDir + '/', '').replace(sourceDir, '');
516
+ const outputPath = join(outputDir, relativePath);
517
+ const content = await readFile(change.name, 'utf8');
518
+ await outputFile(outputPath, content);
519
+ console.log(`✅ Copied ${relativePath}`);
520
+ // Clear script bundle cache so affected documents will regenerate bundles
521
+ clearScriptCache();
522
+ if (watchModeCache.isInitialized) {
523
+ const plan = dependencyTracker.getInvalidationPlan(change.name, sourceDir);
524
+ plan.affectedDocuments.forEach(d => affectedDocPaths.add(d));
525
+ }
526
+ }
527
+
528
+ // --- 4) Handle meta changes ---
529
+ if (metaChanges.length > 0) {
530
+ console.log(`🎨 Processing ${metaChanges.length} meta change(s)`);
531
+ const pub = join(outputDir, 'public');
532
+ clearMetaBundleCache();
533
+ await copyMetaAssets(metaDir, pub);
534
+ const freshTemplates = await getTemplates(metaDir);
535
+ const bundledTemplates = await bundleMetaTemplateAssets(freshTemplates, metaDir, pub, { minify: true, sourcemap: false });
536
+ console.log('🔄 Reloaded and re-bundled meta templates');
537
+
538
+ if (watchModeCache.isInitialized) {
539
+ watchModeCache.templates = bundledTemplates;
540
+ // Check each meta change for its invalidation plan
541
+ for (const change of metaChanges) {
542
+ const plan = dependencyTracker.getMetaInvalidationPlan(change.name, metaDir);
543
+ if (plan.requiresFullRebuild) {
544
+ needsFullRebuild = true;
545
+ fullRebuildReason = plan.reason;
546
+ } else {
547
+ plan.affectedDocuments.forEach(d => affectedDocPaths.add(d));
548
+ }
549
+ }
550
+ } else {
551
+ needsFullRebuild = true;
552
+ fullRebuildReason = 'Cache not initialized';
553
+ }
554
+ }
555
+
556
+ // --- 5) Handle menu/config changes → force full rebuild ---
557
+ if (menuConfigChanges.length > 0) {
558
+ needsFullRebuild = true;
559
+ fullRebuildReason = `Menu/config change: ${menuConfigChanges.map(c => basename(c.name)).join(', ')}`;
560
+ }
561
+
562
+ // --- 5.5) Handle document template changes ---
563
+ // If a _templates/*.md file changed, reconcile all documents using that template
564
+ // and add the affected instance documents to the regeneration set.
565
+ const templateChanges = articleChanges.filter(c => c.name && isInsideTemplatesFolder(c.name));
566
+ if (templateChanges.length > 0 && watchModeCache.isInitialized) {
567
+ const allArticles = watchModeCache.allArticlePaths || [];
568
+ for (const change of templateChanges) {
569
+ console.log(`📄 Document template changed: ${basename(change.name)}`);
570
+ const reconcileResult = await reconcileByTemplate(change.name, allArticles, sourceDir);
571
+ if (reconcileResult.updated > 0 || reconcileResult.conflicts > 0) {
572
+ console.log(` ${reconcileResult.updated} auto-merged, ${reconcileResult.conflicts} conflicts`);
573
+ reconcileResult.affectedPaths.forEach(p => affectedDocPaths.add(p));
574
+ }
575
+ if (reconcileResult.conflicts > 0) {
576
+ for (const msg of reconcileResult.messages) {
577
+ if (msg.includes('Conflict')) console.warn(` ⚠️ ${msg}`);
578
+ }
579
+ }
580
+ }
581
+ }
582
+
583
+ // --- 6) Handle article changes via fast single-file regen ---
584
+ // Deduplicate articles (same file may appear multiple times in rapid saves)
585
+ // Exclude _templates files from direct article regeneration (they aren't rendered)
586
+ const uniqueArticles = [...new Set(articleChanges.map(c => c.name))]
587
+ .filter(name => !isInsideTemplatesFolder(name));
588
+ for (const articlePath of uniqueArticles) {
589
+ affectedDocPaths.add(articlePath);
590
+ }
591
+
592
+ // --- 7) Handle other source changes → full rebuild ---
593
+ if (otherSourceChanges.length > 0) {
594
+ needsFullRebuild = true;
595
+ fullRebuildReason = `Non-standard source change: ${otherSourceChanges.map(c => basename(c.name || 'unknown')).join(', ')}`;
596
+ }
597
+
598
+ // --- 8) Execute rebuild ---
599
+ if (needsFullRebuild) {
600
+ console.log(`📦 Full rebuild required: ${fullRebuildReason}`);
601
+ // Delete on-disk caches on EVERY full-rebuild path (not just menu/config):
602
+ // the content-hash skip only looks at article markdown, so without this a
603
+ // rebuild after a template/meta change would skip every unchanged article
604
+ // and leave stale HTML (see docs/changes/serve-logic.md, root cause #2)
605
+ const ursaDir = join(sourceDir, '.ursa');
606
+ try { await promises.unlink(join(ursaDir, 'content-hashes.json')); } catch {}
607
+ try { await promises.unlink(join(ursaDir, 'nav-cache.json')); } catch {}
608
+ clearWatchCache();
609
+ try {
610
+ const result = await generate({ _source: sourceDir, _meta: metaDir, _output: outputDir, _whitelist, _exclude, _deferImages: true, _deferSearchIndex: true });
611
+ console.log("HTML regeneration complete.");
612
+ if (result?.deferredImageProcessing) {
613
+ result.deferredImageProcessing.then(() => console.log("Image preview generation complete.")).catch(e => console.error("Image processing error:", e.message));
614
+ }
615
+ if (result?.deferredSearchIndex) {
616
+ result.deferredSearchIndex.then(() => console.log("Search index generation complete.")).catch(e => console.error("Search index error:", e.message));
617
+ }
618
+ // Full rebuild: reload all clients
619
+ broadcastReload(uniqueNames[0]);
620
+ } catch (genError) {
621
+ console.error(`❌ Full rebuild failed:`, genError);
622
+ console.error(genError.stack);
623
+ // Still reload — fresh content may be partially written, better than stale
624
+ broadcastReload(uniqueNames[0]);
625
+ }
626
+ } else if (affectedDocPaths.size > 0) {
627
+ // Selective rebuild with priority ordering
628
+ const docPathsArray = [...affectedDocPaths];
629
+
630
+ // Determine which URLs clients are viewing, map to source paths for priority
631
+ const viewedUrls = getClientViewedUrls().map(normalizeUrl);
632
+ const priorityPaths = [];
633
+ const affectedUrlSet = new Set();
634
+
635
+ for (const docPath of docPathsArray) {
636
+ const urls = docPathToUrls(docPath, sourceDir + '/');
637
+ for (const url of urls) {
638
+ affectedUrlSet.add(url);
639
+ if (viewedUrls.includes(url) && !priorityPaths.includes(docPath)) {
640
+ priorityPaths.push(docPath);
641
+ }
642
+ }
643
+ }
644
+
645
+ console.log(`🔀 Selective rebuild: ${docPathsArray.length} docs, ${priorityPaths.length} priority`);
646
+ if (priorityPaths.length > 0) {
647
+ console.log(` Priority: ${priorityPaths.map(p => basename(p)).join(', ')}`);
648
+ }
649
+ console.log(` Client URLs: ${viewedUrls.join(', ') || '(none)'}`);
650
+ console.log(` Affected URLs: ${[...affectedUrlSet].slice(0, 5).join(', ')}${affectedUrlSet.size > 5 ? ` +${affectedUrlSet.size - 5} more` : ''}`);
651
+
652
+ // Notify clients whether the change affects them
653
+ if (affectedUrlSet.size > 0) {
654
+ sendToClientsViewing({ type: 'update-affects-you', timestamp: Date.now() }, affectedUrlSet);
655
+ }
656
+
657
+ const regenResult = await regenerateAffectedDocuments(docPathsArray, {
658
+ _source: sourceDir, _meta: metaDir, _output: outputDir,
659
+ reason: `batch: ${uniqueNames.map(n => basename(n)).join(', ')}`,
660
+ priorityPaths,
661
+ onPriorityComplete: ({ regenerated, failed, priorityDocs }) => {
662
+ if (regenerated > 0) {
663
+ // Immediately reload clients whose pages are now ready
664
+ const readyUrls = new Set(priorityDocs.flatMap(p => docPathToUrls(p, sourceDir + '/')));
665
+ console.log(`⚡ Priority complete: ${regenerated} OK, ${failed} failed → reloading clients`);
666
+ reloadAffectedClients(readyUrls, uniqueNames[0]);
667
+ } else if (failed > 0) {
668
+ console.warn(`⚠️ Priority regen failed for all ${failed} docs — not reloading yet`);
669
+ }
670
+ },
671
+ });
672
+
673
+ // After all remaining docs are done, reload any remaining affected clients
674
+ // (non-priority clients that weren't reloaded during onPriorityComplete)
675
+ const priorityUrlSet = new Set(priorityPaths.flatMap(p => docPathToUrls(p, sourceDir + '/')));
676
+ const remainingUrls = new Set([...affectedUrlSet].filter(u => !priorityUrlSet.has(u)));
677
+ if (remainingUrls.size > 0) {
678
+ reloadAffectedClients(remainingUrls, uniqueNames[0]);
679
+ }
680
+
681
+ // If priority docs all failed, try reloading anyway now that remaining are done
682
+ if (priorityPaths.length > 0 && regenResult.regenerated > 0) {
683
+ const failedPriorityUrls = new Set();
684
+ // Check if any priority was among the failed — reload all affected as fallback
685
+ for (const pp of priorityPaths) {
686
+ failedPriorityUrls.add(docPathToUrl(pp, sourceDir + '/'));
687
+ }
688
+ // If regeneration succeeded overall, make sure all priority clients got reloaded
689
+ for (const [client, clientUrl] of clientUrls) {
690
+ if (client.readyState === 1 && clientUrl && failedPriorityUrls.has(normalizeUrl(clientUrl))) {
691
+ // Client might not have been reloaded if their specific doc failed but others succeeded
692
+ // The onPriorityComplete callback should have handled this, this is a safety net
693
+ }
694
+ }
695
+ }
696
+
697
+ // Clear indicator for clients not affected at all
698
+ for (const [client, clientUrl] of clientUrls) {
699
+ if (client.readyState === 1 && clientUrl && !affectedUrlSet.has(normalizeUrl(clientUrl))) {
700
+ client.send(JSON.stringify({ type: 'update-no-affect', timestamp: Date.now() }));
701
+ }
702
+ }
703
+ } else {
704
+ // No documents affected (e.g. static-only changes) — reload all clients
705
+ // (meta static assets were re-copied to output/public in step 4)
706
+ if (staticChanges.length > 0 || metaStaticChanges.length > 0) {
707
+ broadcastReload(uniqueNames[0]);
708
+ } else {
709
+ // Nothing to do — clear indicators
710
+ broadcastMessage({ type: 'update-no-affect', timestamp: Date.now() });
711
+ }
712
+ }
713
+ } catch (error) {
714
+ console.error(`❌ Error during batch processing:`, error);
715
+ console.error(error.stack);
716
+ // Reload clients as fallback — stale content with a reload is better than a stuck spinner
717
+ broadcastReload();
718
+ } finally {
719
+ isRegenerating = false;
720
+ // Process changes that arrived during this pass (sequentially, never dropped)
721
+ if (queuedDuringRegeneration.length > 0) {
722
+ const nextBatch = queuedDuringRegeneration.splice(0);
723
+ console.log(`▶️ Processing ${nextBatch.length} change(s) queued during the last pass`);
724
+ setImmediate(() => processChangeBatch(nextBatch, sourceDir, metaDir, outputDir, _whitelist, _exclude));
725
+ }
726
+ }
727
+ }
728
+
729
+ // Meta changes: queue for debounced batch processing.
730
+ // Includes static asset extensions (images, fonts, media, PDFs) so that
731
+ // replacing e.g. a PNG or font in meta/ re-runs copyMetaAssets + re-bundling
732
+ // instead of being invisible to the watcher.
733
+ watch(metaDir, { recursive: true, filter: /\.(js|json|css|html|md|txt|yml|yaml|jpg|jpeg|png|gif|webp|svg|ico|woff|woff2|ttf|eot|pdf|mp3|mp4|webm|ogg)$/i }, (evt, name) => {
734
+ queueChange(evt, name, 'meta');
735
+ });
736
+
737
+ // Source changes: queue for debounced batch processing
738
+ watch(sourceDir, {
739
+ recursive: true,
740
+ filter: (f, skip) => {
741
+ // Skip .ursa folder (contains hash cache that gets updated during generation)
742
+ if (/[\/\\]\.ursa[\/\\]?/.test(f)) return skip;
743
+ // Watch article files, config files, and static assets
744
+ return /\.(js|json|css|html|md|mdx|txt|yml|yaml|tsx|ts|jsx|jpg|jpeg|png|gif|webp|svg|ico|woff|woff2|ttf|eot|pdf|mp3|mp4|webm|ogg)$/i.test(f);
745
+ }
746
+ }, (evt, name) => {
747
+ queueChange(evt, name, 'source');
748
+ });
749
+ }
750
+
751
+ /**
752
+ * Start HTTP server to serve static files with hot reload support
753
+ * @param {string} outputDir - Directory to serve files from
754
+ * @param {number} port - HTTP server port
755
+ * @returns {object} Object containing httpServer and wsPort
756
+ */
757
+ function serveFiles(outputDir, port = 8080) {
758
+ const app = express();
759
+ const wsPort = port + 1; // WebSocket on port+1
760
+
761
+ // Enable gzip compression for all responses
762
+ // This significantly reduces transfer size for JSON and HTML files
763
+ app.use(compression({
764
+ // Compress everything over 1KB
765
+ threshold: 1024,
766
+ // Use default compression level (good balance of speed vs size)
767
+ level: 6
768
+ }));
769
+
770
+ // Add ursa-version and doc-version headers to all JSON responses
771
+ // (per-document JSON, directory index arrays, and public/*.json index files)
772
+ app.use((req, res, next) => {
773
+ if (req.path.endsWith('.json')) {
774
+ const meta = watchModeCache.ursaMetadata || {};
775
+ res.setHeader('X-ursa-version', meta.ursaVersion || 'unknown');
776
+ res.setHeader('X-doc-version', meta.docVersion || 'unknown');
777
+ }
778
+ next();
779
+ });
780
+
781
+ // Middleware to inject hot reload script into HTML responses
782
+ app.use(async (req, res, next) => {
783
+ // Only intercept HTML requests
784
+ const url = req.url;
785
+ const isHtmlRequest = url.endsWith('.html') ||
786
+ url.endsWith('/') ||
787
+ !url.includes('.') ||
788
+ url === '/';
789
+
790
+ if (!isHtmlRequest) {
791
+ return next();
792
+ }
793
+
794
+ // Determine the file path
795
+ let filePath;
796
+ if (url === '/' || url.endsWith('/')) {
797
+ filePath = join(outputDir, url, 'index.html');
798
+ } else if (url.endsWith('.html')) {
799
+ filePath = join(outputDir, url);
800
+ } else {
801
+ // Try adding .html extension
802
+ filePath = join(outputDir, url + '.html');
803
+ if (!fs.existsSync(filePath)) {
804
+ filePath = join(outputDir, url, 'index.html');
805
+ }
806
+ }
807
+
808
+ try {
809
+ if (fs.existsSync(filePath)) {
810
+ let html = await readFile(filePath, 'utf8');
811
+ // Inject hot reload script before </body>
812
+ const hotReloadScript = getHotReloadScript(wsPort);
813
+ if (html.includes('</body>')) {
814
+ html = html.replace('</body>', hotReloadScript + '</body>');
815
+ } else {
816
+ html += hotReloadScript;
817
+ }
818
+ res.setHeader('Content-Type', 'text/html');
819
+ res.send(html);
820
+ } else {
821
+ next();
822
+ }
823
+ } catch (error) {
824
+ next();
825
+ }
826
+ });
827
+
828
+ // Fallback static file serving for non-HTML files
829
+ app.use(
830
+ express.static(outputDir, { extensions: ["html"], index: "index.html" })
831
+ );
832
+
833
+ // Create HTTP server
834
+ const httpServer = createServer(app);
835
+
836
+ // Create WebSocket server for hot reload
837
+ wss = new WebSocketServer({ port: wsPort });
838
+
839
+ wss.on('connection', (ws) => {
840
+ // Send a ping to keep connection alive
841
+ const pingInterval = setInterval(() => {
842
+ if (ws.readyState === 1) {
843
+ ws.ping();
844
+ }
845
+ }, 30000);
846
+
847
+ // Handle messages from the client (URL tracking)
848
+ ws.on('message', (data) => {
849
+ try {
850
+ const msg = JSON.parse(data.toString());
851
+ if (msg.type === 'url' && msg.url) {
852
+ clientUrls.set(ws, msg.url);
853
+ }
854
+ } catch (e) { /* ignore non-JSON messages */ }
855
+ });
856
+
857
+ ws.on('close', () => {
858
+ clearInterval(pingInterval);
859
+ clientUrls.delete(ws);
860
+ });
861
+ });
862
+
863
+ httpServer.listen(port, () => {
864
+ console.log(`🌐 Server listening on port ${port}`);
865
+ console.log(`🔥 Hot reload WebSocket on port ${wsPort}`);
866
+ });
867
+
868
+ return { httpServer, wsPort };
869
+ }
870
+
871
+ /**
872
+ * we're only interested in meta (and maybe, in the future, source)
873
+ * for src changes, we need the node process to restart
874
+ */
875
+ function filter(filename, skip) {
876
+ // console.log("testing ", filename);
877
+ if (/\/build/.test(filename)) return skip;
878
+ if (/\/node_modules/.test(filename)) return skip;
879
+ if (/\.git/.test(filename)) return skip;
880
+ if (/\/src/.test(filename)) return skip;
881
+ if (/\/meta/.test(filename)) return true;
882
+ return false;
883
+ }
884
+
885
+ // Default serve function for backward compatibility (only run when executed directly)
886
+ if (import.meta.url === `file://${process.argv[1]}`) {
887
+ const source = resolve(process.env.SOURCE ?? join(process.cwd(), "source"));
888
+ const meta = resolve(process.env.META ?? join(process.cwd(), "meta"));
889
+ const output = resolve(process.env.OUTPUT ?? join(process.cwd(), "build"));
890
+
891
+ console.log({ source, meta, output });
892
+
893
+ await generate({ _source: source, _meta: meta, _output: output });
894
+ console.log("done generating. now serving...");
895
+
896
+ serveFiles(output);
897
+
898
+ watch(meta, { recursive: true }, async (evt, name) => {
899
+ console.log("meta files changed! generating output");
900
+ await generate({ _source: source, _meta: meta, _output: output });
901
+ });
902
+
903
+ watch(source, { recursive: true }, async (evt, name) => {
904
+ console.log("source files changed! generating output");
905
+ await generate({ _source: source, _meta: meta, _output: output });
906
+ });
907
+ }