@kenjura/ursa 0.96.0 → 0.97.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.
Files changed (44) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +72 -16
  3. package/bin/ursa.js +14 -1
  4. package/meta/templates/default-template/menu.js +18 -1
  5. package/meta/templates/default-template/search.js +11 -0
  6. package/meta/templates/default-template/widgets.js +4 -0
  7. package/package.json +1 -2
  8. package/src/dev.js +13 -23
  9. package/src/helper/__test__/contentHash.test.js +16 -6
  10. package/src/helper/assetBundler.js +93 -19
  11. package/src/helper/automenu.js +36 -11
  12. package/src/helper/build/__test__/autoIndex.test.js +2 -132
  13. package/src/helper/build/__test__/graph.test.js +259 -3
  14. package/src/helper/build/__test__/pass.test.js +553 -0
  15. package/src/helper/build/autoIndex.js +2 -371
  16. package/src/helper/build/excludeFilter.js +1 -2
  17. package/src/helper/build/footer.js +27 -14
  18. package/src/helper/build/graph.js +575 -152
  19. package/src/helper/build/index.js +0 -2
  20. package/src/helper/build/metadata.js +19 -5
  21. package/src/helper/build/pass.js +497 -0
  22. package/src/helper/build/precedence.js +174 -0
  23. package/src/helper/build/site.js +1270 -0
  24. package/src/helper/build/templates.js +1 -2
  25. package/src/helper/build/tracedFs.js +247 -0
  26. package/src/helper/contentHash.js +0 -78
  27. package/src/helper/customMenu.js +1 -1
  28. package/src/helper/fileRenderer.js +119 -111
  29. package/src/helper/findScriptJs.js +1 -1
  30. package/src/helper/findStyleCss.js +1 -1
  31. package/src/helper/folderConfig.js +7 -18
  32. package/src/helper/fullTextIndex.js +41 -29
  33. package/src/helper/imageProcessor.js +45 -0
  34. package/src/helper/linkValidator.js +118 -127
  35. package/src/helper/mdxRenderer.js +27 -5
  36. package/src/helper/menuLabels.js +30 -5
  37. package/src/helper/whitelistFilter.js +1 -2
  38. package/src/jobs/generate.js +67 -1829
  39. package/src/serve.js +317 -697
  40. package/src/helper/__test__/dependencyTracker.test.js +0 -157
  41. package/src/helper/build/cacheBust.js +0 -141
  42. package/src/helper/build/navCache.js +0 -145
  43. package/src/helper/build/watchCache.js +0 -33
  44. package/src/helper/dependencyTracker.js +0 -384
package/src/serve.js CHANGED
@@ -1,170 +1,77 @@
1
+ /**
2
+ * `ursa serve`: build the site, serve it, and keep the output continuously
3
+ * equal to what a build from the current source would produce, telling
4
+ * connected browsers when the page they are looking at has changed.
5
+ *
6
+ * See docs/SERVE.md. In short:
7
+ *
8
+ * - Both trees (docroot and meta) are watched recursively with a deny-list,
9
+ * never an extension allow-list (§3.1). Events are normalised to paths; the
10
+ * truth about a path is established by the pass, not the event kind (§3.2).
11
+ * - Events are debounced: 500 ms quiet, 2000 ms at most (§3.3). A batch runs
12
+ * one pass. Exactly one pass runs at a time; events during a pass form the
13
+ * next batch (§5.5). The startup pass holds the same lock.
14
+ * - The pass (helper/build/pass.js) is the same one `generate` runs. Viewed
15
+ * pages are built first and their clients told to reload the moment the page
16
+ * is written (§6.4); pages whose JSON (menu, indices) changed are told
17
+ * `data-updated` and refetch in place (§6.1).
18
+ */
19
+
1
20
  import express from "express";
2
21
  import compression from "compression";
3
22
  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";
23
+ import { join, resolve, relative, basename } from "path";
24
+ import { existsSync, statSync } from "fs";
25
+ import { promises as fsp } from "fs";
16
26
  import { WebSocketServer } from "ws";
17
27
  import { createServer } from "http";
18
28
  import { resolvePort } from "./helper/portUtils.js";
19
- const { readdir, mkdir, readFile, copyFile } = promises;
29
+ import { createBuild } from "./helper/build/pass.js";
30
+ import { resolveUrlToOutput } from "./helper/build/precedence.js";
31
+ import { hashBytes, isScratchName } from "./helper/build/tracedFs.js";
32
+ import { parseNodeId } from "./helper/build/site.js";
33
+ import { getUrsaVersion } from "./helper/ursaVersion.js";
20
34
 
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
- }
35
+ const { readFile, mkdir } = fsp;
41
36
 
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
- }
37
+ const DEBOUNCE_QUIET_MS = 500;
38
+ const DEBOUNCE_MAX_MS = 2000;
59
39
 
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
- }
40
+ // ---------------------------------------------------------------------------
41
+ // Clients
42
+ // ---------------------------------------------------------------------------
73
43
 
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
- }
44
+ /** Map of WebSocket client → the URL path it reports viewing. */
45
+ const clientUrls = new Map();
46
+ let wss = null;
97
47
 
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
- });
48
+ function send(client, messageObj) {
49
+ if (client.readyState === 1) client.send(JSON.stringify(messageObj));
108
50
  }
109
51
 
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) {
52
+ function broadcast(messageObj) {
117
53
  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
- }
54
+ for (const client of wss.clients) send(client, messageObj);
124
55
  }
125
56
 
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' : ''}`);
57
+ /** The distinct output paths (relative) connected clients are viewing. */
58
+ function viewedOutputs(outputDir) {
59
+ const out = new Set();
60
+ for (const [client, url] of clientUrls) {
61
+ if (client.readyState !== 1 || !url) continue;
62
+ out.add(outputForUrl(url, outputDir));
135
63
  }
64
+ return [...out];
136
65
  }
137
66
 
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
- }
67
+ /** URL → the output file it names (shared with the HTTP middleware, §6.2). */
68
+ function outputForUrl(url, outputDir) {
69
+ return resolveUrlToOutput(url, (rel) => existsSync(join(outputDir, rel)));
162
70
  }
163
71
 
164
72
  /**
165
- * Generate the hot reload client script
73
+ * Generate the hot reload client script.
166
74
  * @param {number} wsPort - WebSocket server port
167
- * @returns {string} JavaScript code to inject
168
75
  */
169
76
  function getHotReloadScript(wsPort) {
170
77
  return `
@@ -205,13 +112,13 @@ function getHotReloadScript(wsPort) {
205
112
 
206
113
  function connect() {
207
114
  ws = new WebSocket(wsUrl);
208
-
115
+
209
116
  ws.onopen = function() {
210
117
  console.log('[Ursa] Hot reload connected');
211
118
  reconnectAttempts = 0;
212
119
  sendUrl();
213
120
  };
214
-
121
+
215
122
  ws.onmessage = function(event) {
216
123
  try {
217
124
  const data = JSON.parse(event.data);
@@ -230,12 +137,21 @@ function getHotReloadScript(wsPort) {
230
137
  case 'update-no-affect':
231
138
  hideIndicator();
232
139
  break;
140
+ case 'update-failed':
141
+ hideIndicator();
142
+ console.error('[Ursa] Rebuilding this page failed: ' + (data.message || 'unknown error'));
143
+ break;
144
+ case 'data-updated':
145
+ // Menu, search indices or recent activity changed: the page's
146
+ // scripts refetch them in place, no reload needed.
147
+ document.dispatchEvent(new CustomEvent('ursa:data-updated', { detail: { what: data.what || [] } }));
148
+ break;
233
149
  }
234
150
  } catch (e) {
235
151
  console.error('[Ursa] Hot reload error:', e);
236
152
  }
237
153
  };
238
-
154
+
239
155
  ws.onclose = function() {
240
156
  if (reconnectAttempts < maxReconnectAttempts) {
241
157
  reconnectAttempts++;
@@ -245,17 +161,17 @@ function getHotReloadScript(wsPort) {
245
161
  console.log('[Ursa] Hot reload: max reconnect attempts reached');
246
162
  }
247
163
  };
248
-
249
- ws.onerror = function(error) {
164
+
165
+ ws.onerror = function() {
250
166
  console.error('[Ursa] Hot reload WebSocket error');
251
167
  };
252
168
  }
253
-
169
+
254
170
  connect();
255
171
 
256
- // Track navigation (SPA-style or hash changes)
172
+ // Tell the server which page this is, whenever that might have changed
257
173
  window.addEventListener('popstate', sendUrl);
258
- // Also re-send on page visibility change (e.g. tab switch)
174
+ window.addEventListener('pageshow', sendUrl);
259
175
  document.addEventListener('visibilitychange', function() {
260
176
  if (!document.hidden) sendUrl();
261
177
  });
@@ -264,86 +180,9 @@ function getHotReloadScript(wsPort) {
264
180
  `;
265
181
  }
266
182
 
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
- }
183
+ // ---------------------------------------------------------------------------
184
+ // Serve
185
+ // ---------------------------------------------------------------------------
347
186
 
348
187
  /**
349
188
  * Configurable serve function for CLI and library use
@@ -356,7 +195,8 @@ export async function serve({
356
195
  _whitelist = null,
357
196
  _clean = false,
358
197
  _exclude = null,
359
- strictPort = false
198
+ _explain = false,
199
+ strictPort = false,
360
200
  } = {}) {
361
201
  const sourceDir = resolve(_source);
362
202
  const metaDir = resolve(_meta);
@@ -369,385 +209,238 @@ export async function serve({
369
209
 
370
210
  // Ensure output directory exists and start server immediately
371
211
  await mkdir(outputDir, { recursive: true });
372
- serveFiles(outputDir, port);
212
+ const { wsPort } = serveFiles(outputDir, port);
373
213
  console.log(`🚀 Development server running at http://localhost:${port}`);
374
214
  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
215
 
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");
216
+ const build = await createBuild({
217
+ source: sourceDir,
218
+ meta: metaDir,
219
+ output: outputDir,
220
+ whitelist: _whitelist,
221
+ exclude: _exclude,
222
+ clean: _clean,
223
+ explain: _explain,
224
+ });
225
+
226
+ // ---- Batching and the single writer ------------------------------------
227
+
228
+ let pending = new Set(); // paths with events in the current batch
229
+ let quietTimer = null;
230
+ let maxTimer = null;
231
+ let batchStartedAt = 0;
232
+ let running = false;
415
233
 
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() });
234
+ const excludedRoots = [
235
+ outputDir,
236
+ join(sourceDir, ".ursa"),
237
+ join(sourceDir, ".ursa.json"),
238
+ ];
239
+
240
+ /** Paths never treated as inputs (§3.1). */
241
+ function isIgnoredPath(path) {
242
+ for (const root of excludedRoots) {
243
+ if (path === root || path.startsWith(root + "/")) return true;
244
+ }
245
+ const parts = path.split("/");
246
+ for (const part of parts) {
247
+ if (part === "node_modules" || part === ".git") return true;
425
248
  }
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);
249
+ const name = basename(path);
250
+ if (isScratchName(name)) return true;
251
+ // Editor scratch files inside otherwise-visible folders
252
+ if (name.startsWith(".") && name !== ".ursa") return true;
253
+ return false;
435
254
  }
436
255
 
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;
256
+ function queueChange(evt, path) {
257
+ if (isIgnoredPath(path)) return;
258
+ if (pending.size === 0 && !running) {
259
+ broadcast({ type: "update-start", timestamp: Date.now() });
260
+ batchStartedAt = Date.now();
449
261
  }
450
- isRegenerating = true;
262
+ pending.add(path);
263
+
264
+ if (quietTimer) clearTimeout(quietTimer);
265
+ quietTimer = setTimeout(startBatch, DEBOUNCE_QUIET_MS);
266
+ if (!maxTimer) {
267
+ maxTimer = setTimeout(startBatch, DEBOUNCE_MAX_MS);
268
+ }
269
+ }
270
+
271
+ function startBatch() {
272
+ if (quietTimer) clearTimeout(quietTimer);
273
+ if (maxTimer) clearTimeout(maxTimer);
274
+ quietTimer = null;
275
+ maxTimer = null;
276
+ if (running) return; // the running pass will pick the batch up when it ends
277
+ runQueued();
278
+ }
451
279
 
280
+ async function runQueued() {
281
+ if (running) return;
282
+ if (pending.size === 0) return;
283
+ running = true;
284
+ const paths = [...pending];
285
+ pending = new Set();
452
286
  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}`);
287
+ // A directory event (creation, removal, rename) rescans its subtree: the
288
+ // watcher may report a renamed or removed folder as one event for the
289
+ // folder alone (§3.2). A path that is gone might have been a folder, so
290
+ // every known leaf beneath it is re-checked too.
291
+ const subtrees = [];
292
+ for (const p of paths) {
293
+ let isDir = false;
294
+ try {
295
+ isDir = statSync(p).isDirectory();
296
+ } catch {
297
+ isDir = true; // missing: rescan whatever the graph knew beneath it
493
298
  }
299
+ if (isDir) subtrees.push(p);
300
+ else build.graph.invalidatePath(p);
494
301
  }
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
- }
302
+ build.graph.invalidateSubtrees(subtrees);
303
+ console.log(`\n🔄 ${paths.length} change(s) after ${Date.now() - batchStartedAt}ms: ${paths.slice(0, 5).map((p) => relative(sourceDir, p) || p).join(", ")}${paths.length > 5 ? ", …" : ""}`);
304
+ await runPassWithClients();
305
+ } catch (e) {
306
+ console.error("Error during regeneration:", e);
307
+ broadcast({ type: "update-no-affect", timestamp: Date.now() });
308
+ } finally {
309
+ running = false;
310
+ // Changes that arrived while the pass ran: next batch, no debounce (§5.5)
311
+ if (pending.size > 0) {
312
+ console.log(`▶️ Processing ${pending.size} change(s) queued during the last pass`);
313
+ setImmediate(runQueued);
511
314
  }
315
+ }
316
+ }
512
317
 
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));
318
+ // ---- A pass, with client notifications ----------------------------------
319
+
320
+ async function runPassWithClients() {
321
+ // Snapshot what each viewed page looks like now, so "changed" means the
322
+ // bytes the browser would fetch changed — whichever node ends up owning it
323
+ const viewed = viewedOutputs(outputDir);
324
+ const before = new Map();
325
+ for (const rel of viewed) before.set(rel, await fileHash(join(outputDir, rel)));
326
+ const notified = new Set(); // output rels already told to reload/fail
327
+ const nodeToOutputs = new Map(); // node id → viewed output rels it owns
328
+
329
+ const summary = await build.runPass({
330
+ viewedOutputs: viewed,
331
+ onDirty: (dirty) => {
332
+ // Green indicator for clients whose page (by current owner) is in the dirty set
333
+ for (const [client, url] of clientUrls) {
334
+ if (client.readyState !== 1 || !url) continue;
335
+ const rel = outputForUrl(url, outputDir);
336
+ const owner = build.graph.ownerOfPath(rel);
337
+ if (owner && dirty.has(owner)) send(client, { type: "update-affects-you", timestamp: Date.now() });
525
338
  }
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';
339
+ for (const rel of viewed) {
340
+ const owner = build.graph.ownerOfPath(rel);
341
+ if (owner) nodeToOutputs.set(owner, [...(nodeToOutputs.get(owner) ?? []), rel]);
553
342
  }
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));
343
+ },
344
+ onNodeDone: async (id, err) => {
345
+ // Early reload (§6.4): the moment a viewed page's owner has been written
346
+ const rels = new Set(nodeToOutputs.get(id) ?? []);
347
+ for (const rel of build.graph.ownedBy(id)) if (before.has(rel)) rels.add(rel);
348
+ for (const rel of rels) {
349
+ if (notified.has(rel)) continue;
350
+ if (err) {
351
+ notified.add(rel);
352
+ sendToViewers(rel, { type: "update-failed", message: err.cause?.message ?? err.message, timestamp: Date.now() });
353
+ continue;
574
354
  }
575
- if (reconcileResult.conflicts > 0) {
576
- for (const msg of reconcileResult.messages) {
577
- if (msg.includes('Conflict')) console.warn(` ⚠️ ${msg}`);
578
- }
355
+ // Claim before the await: the same node is a root of more than one phase
356
+ notified.add(rel);
357
+ const after = await fileHash(join(outputDir, rel));
358
+ if (after !== before.get(rel)) {
359
+ sendToViewers(rel, { type: "reload", timestamp: Date.now() });
360
+ } else {
361
+ notified.delete(rel);
579
362
  }
580
363
  }
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
- }
364
+ },
365
+ moreViewed: () => viewedOutputs(outputDir).filter((rel) => !viewed.includes(rel)),
366
+ });
591
367
 
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(', ')}`;
368
+ // End of pass: anything not yet told. A page whose owner moved (auto-index
369
+ // took over a deleted index.md) or that was deleted reloads to the truth.
370
+ for (const [client, url] of clientUrls) {
371
+ if (client.readyState !== 1 || !url) continue;
372
+ const rel = outputForUrl(url, outputDir);
373
+ if (notified.has(rel)) continue;
374
+ const prev = before.has(rel) ? before.get(rel) : await fileHash(join(outputDir, rel));
375
+ const after = await fileHash(join(outputDir, rel));
376
+ if (before.has(rel) && after !== prev) {
377
+ send(client, { type: "reload", timestamp: Date.now() });
378
+ } else {
379
+ send(client, { type: "update-no-affect", timestamp: Date.now() });
596
380
  }
381
+ }
597
382
 
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
- }
383
+ // Soft closure (§6.1): JSON the page fetches after load
384
+ const what = [];
385
+ for (const id of summary.changedNodes) {
386
+ const { kind } = parseNodeId(id);
387
+ if (kind === "menuData" || kind === "customMenu") what.push("menu");
388
+ else if (kind === "searchIndex" || kind === "fullTextIndex") what.push("search");
389
+ else if (kind === "recentActivity") what.push("recent-activity");
390
+ }
391
+ if (what.length > 0) {
392
+ broadcast({ type: "data-updated", what: [...new Set(what)], timestamp: Date.now() });
393
+ }
394
+ return summary;
395
+ }
644
396
 
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` : ''}`);
397
+ function sendToViewers(rel, message) {
398
+ for (const [client, url] of clientUrls) {
399
+ if (client.readyState !== 1 || !url) continue;
400
+ if (outputForUrl(url, outputDir) === rel) send(client, message);
401
+ }
402
+ }
651
403
 
652
- // Notify clients whether the change affects them
653
- if (affectedUrlSet.size > 0) {
654
- sendToClientsViewing({ type: 'update-affects-you', timestamp: Date.now() }, affectedUrlSet);
655
- }
404
+ // ---- Startup: the first pass runs behind the same lock -------------------
656
405
 
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
- }
406
+ console.log("👀 Watching for changes in:");
407
+ console.log(" Source:", sourceDir);
408
+ console.log(" Meta:", metaDir);
409
+ console.log("\nPress Ctrl+C to stop the server\n");
680
410
 
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
- }
411
+ const watcherOpts = { recursive: true, filter: (f, skip) => (isIgnoredPath(f) ? skip : true) };
412
+ watch(metaDir, watcherOpts, (evt, name) => queueChange(evt, name));
413
+ watch(sourceDir, watcherOpts, (evt, name) => queueChange(evt, name));
696
414
 
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
- }
415
+ running = true;
416
+ try {
417
+ console.log("⏳ Initial build…");
418
+ await runPassWithClients();
419
+ console.log("\n✅ Site ready.\n");
420
+ } catch (e) {
421
+ console.error("Error during initial generation:", e);
422
+ } finally {
423
+ running = false;
424
+ if (pending.size > 0) setImmediate(runQueued);
727
425
  }
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
- });
426
+ }
736
427
 
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
- });
428
+ function escapeHtml(text) {
429
+ return String(text).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
749
430
  }
750
431
 
432
+ async function fileHash(path) {
433
+ try {
434
+ return hashBytes(await readFile(path));
435
+ } catch {
436
+ return null;
437
+ }
438
+ }
439
+
440
+ // ---------------------------------------------------------------------------
441
+ // HTTP
442
+ // ---------------------------------------------------------------------------
443
+
751
444
  /**
752
445
  * Start HTTP server to serve static files with hot reload support
753
446
  * @param {string} outputDir - Directory to serve files from
@@ -758,103 +451,67 @@ function serveFiles(outputDir, port = 8080) {
758
451
  const app = express();
759
452
  const wsPort = port + 1; // WebSocket on port+1
760
453
 
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)
454
+ app.use(compression({ threshold: 1024, level: 6 }));
455
+
456
+ // Nothing served here may be cached: the same URL (bundle URLs carry content
457
+ // hashes, JSON carries the session's build id) can change under a browser
458
+ // that keeps a tab open across edits.
772
459
  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');
460
+ res.setHeader("Cache-Control", "no-store");
461
+ if (req.path.endsWith(".json")) {
462
+ res.setHeader("X-ursa-version", getUrsaVersion());
777
463
  }
778
464
  next();
779
465
  });
780
466
 
781
- // Middleware to inject hot reload script into HTML responses
467
+ // Pages: one resolver shared with the reload logic (§6.2), hot reload script injected
782
468
  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
-
469
+ const urlPath = req.path;
470
+ const rel = resolveUrlToOutput(urlPath, (r) => existsSync(join(outputDir, r)));
471
+ if (!rel.endsWith(".html")) return next();
472
+ const filePath = join(outputDir, rel);
473
+ if (!filePath.startsWith(outputDir + "/") || !existsSync(filePath)) return next();
808
474
  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) {
475
+ let html = await readFile(filePath, "utf8");
476
+ const hotReloadScript = getHotReloadScript(wsPort);
477
+ html = html.includes("</body>") ? html.replace("</body>", hotReloadScript + "</body>") : html + hotReloadScript;
478
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
479
+ res.send(html);
480
+ } catch {
824
481
  next();
825
482
  }
826
483
  });
827
484
 
828
- // Fallback static file serving for non-HTML files
829
- app.use(
830
- express.static(outputDir, { extensions: ["html"], index: "index.html" })
831
- );
485
+ // Everything else
486
+ app.use(express.static(outputDir, { extensions: ["html"], index: "index.html", etag: false, lastModified: false, cacheControl: false }));
487
+
488
+ // A page that does not exist (yet). It carries the hot reload script too, so
489
+ // a browser parked on a URL reloads the moment something is written there —
490
+ // a document created after the tab was opened, or a folder renamed back.
491
+ app.use((req, res, next) => {
492
+ const rel = resolveUrlToOutput(req.path, (r) => existsSync(join(outputDir, r)));
493
+ if (!rel.endsWith(".html") || req.method !== "GET") return next();
494
+ res.status(404);
495
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
496
+ res.send(`<!DOCTYPE html><html><head><meta charset="UTF-8"><title>Not found</title></head><body><h1>404 Not Found</h1><p><code>${escapeHtml(req.path)}</code> is not part of the site (yet). This page reloads when it appears.</p>${getHotReloadScript(wsPort)}</body></html>`);
497
+ });
832
498
 
833
- // Create HTTP server
834
499
  const httpServer = createServer(app);
835
-
836
- // Create WebSocket server for hot reload
500
+
837
501
  wss = new WebSocketServer({ port: wsPort });
838
-
839
- wss.on('connection', (ws) => {
840
- // Send a ping to keep connection alive
502
+ wss.on("connection", (ws) => {
841
503
  const pingInterval = setInterval(() => {
842
- if (ws.readyState === 1) {
843
- ws.ping();
844
- }
504
+ if (ws.readyState === 1) ws.ping();
845
505
  }, 30000);
846
-
847
- // Handle messages from the client (URL tracking)
848
- ws.on('message', (data) => {
506
+ ws.on("message", (data) => {
849
507
  try {
850
508
  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 */ }
509
+ if (msg.type === "url" && msg.url) clientUrls.set(ws, msg.url);
510
+ } catch {
511
+ // ignore non-JSON messages
512
+ }
855
513
  });
856
-
857
- ws.on('close', () => {
514
+ ws.on("close", () => {
858
515
  clearInterval(pingInterval);
859
516
  clientUrls.delete(ws);
860
517
  });
@@ -864,44 +521,7 @@ function serveFiles(outputDir, port = 8080) {
864
521
  console.log(`🌐 Server listening on port ${port}`);
865
522
  console.log(`🔥 Hot reload WebSocket on port ${wsPort}`);
866
523
  });
867
-
868
- return { httpServer, wsPort };
869
- }
870
524
 
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;
525
+ return { httpServer, wsPort };
883
526
  }
884
527
 
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
- }