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