@kenjura/ursa 0.76.0 → 0.78.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 (35) hide show
  1. package/CHANGELOG.md +41 -17
  2. package/README.md +1 -1
  3. package/meta/default.css +33 -0
  4. package/meta/templates/default-template/default.css +1268 -0
  5. package/meta/{default-template.html → templates/default-template/index.html} +15 -0
  6. package/meta/{menu.js → templates/default-template/menu.js} +1 -1
  7. package/meta/templates/default-template/sectionify.js +46 -0
  8. package/meta/{widgets.js → templates/default-template/widgets.js} +126 -0
  9. package/package.json +4 -2
  10. package/src/dev.js +73 -28
  11. package/src/helper/assetBundler.js +471 -0
  12. package/src/helper/build/autoIndex.js +24 -23
  13. package/src/helper/build/cacheBust.js +79 -0
  14. package/src/helper/build/navCache.js +4 -0
  15. package/src/helper/build/templates.js +176 -19
  16. package/src/helper/build/watchCache.js +7 -0
  17. package/src/helper/customMenu.js +4 -2
  18. package/src/helper/dependencyTracker.js +269 -0
  19. package/src/helper/findStyleCss.js +29 -0
  20. package/src/helper/portUtils.js +132 -0
  21. package/src/jobs/generate.js +234 -62
  22. package/src/serve.js +446 -162
  23. package/meta/character-sheet.css +0 -50
  24. /package/meta/{goudy_bookletter_1911-webfont.woff → shared/goudy_bookletter_1911-webfont.woff} +0 -0
  25. /package/meta/{character-sheet/css → templates/character-sheet-template}/character-sheet.css +0 -0
  26. /package/meta/{character-sheet/js → templates/character-sheet-template}/components.js +0 -0
  27. /package/meta/{cssui.bundle.min.css → templates/character-sheet-template/cssui.bundle.min.css} +0 -0
  28. /package/meta/{character-sheet-template.html → templates/character-sheet-template/index.html} +0 -0
  29. /package/meta/{character-sheet/js → templates/character-sheet-template}/main.js +0 -0
  30. /package/meta/{character-sheet/js → templates/character-sheet-template}/model.js +0 -0
  31. /package/meta/{search.js → templates/default-template/search.js} +0 -0
  32. /package/meta/{sticky.js → templates/default-template/sticky.js} +0 -0
  33. /package/meta/{toc-generator.js → templates/default-template/toc-generator.js} +0 -0
  34. /package/meta/{toc.js → templates/default-template/toc.js} +0 -0
  35. /package/meta/{template2.html → templates/template2/index.html} +0 -0
package/src/serve.js CHANGED
@@ -1,42 +1,140 @@
1
1
  import express from "express";
2
2
  import compression from "compression";
3
3
  import watch from "node-watch";
4
- import { generate, regenerateSingleFile, clearWatchCache } from "./jobs/generate.js";
4
+ import { generate, regenerateAffectedDocuments, clearWatchCache } from "./jobs/generate.js";
5
5
  import { join, resolve, dirname, basename } from "path";
6
6
  import fs from "fs";
7
7
  import { promises } from "fs";
8
- import { outputFile } from "fs-extra";
8
+ import { copy as copyDir, outputFile } from "fs-extra";
9
9
  import { processImage } from "./helper/imageProcessor.js";
10
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";
11
14
  import { WebSocketServer } from "ws";
12
15
  import { createServer } from "http";
16
+ import { resolvePort } from "./helper/portUtils.js";
13
17
  const { readdir, mkdir, readFile, copyFile } = promises;
14
18
 
15
19
  // WebSocket server for hot reloading
16
20
  let wss = null;
17
21
 
18
22
  /**
19
- * Broadcast a reload message to all connected clients
20
- * @param {string} [changedFile] - Optional path of the changed file
23
+ * Map of WebSocket client current page URL path (e.g. '/campaigns/abs/index.html')
24
+ * Updated when clients send { type: 'url', url: '...' } messages.
21
25
  */
22
- function broadcastReload(changedFile = null) {
26
+ const clientUrls = new Map();
27
+
28
+ /**
29
+ * Get URL paths that connected WebSocket clients are currently viewing.
30
+ * @returns {string[]} Array of unique URL paths
31
+ */
32
+ function getClientViewedUrls() {
33
+ const urls = new Set();
34
+ for (const [client, url] of clientUrls) {
35
+ if (client.readyState === 1 && url) urls.add(url);
36
+ }
37
+ return [...urls];
38
+ }
39
+
40
+ /**
41
+ * Normalize a URL path for comparison.
42
+ * Converts /path/index.html → /path/, /path.html → /path.html
43
+ * Strips trailing whitespace. Ensures leading /.
44
+ * @param {string} url
45
+ * @returns {string}
46
+ */
47
+ function normalizeUrl(url) {
48
+ if (!url) return '/';
49
+ let u = url.trim();
50
+ if (!u.startsWith('/')) u = '/' + u;
51
+ // /foo/index.html → /foo/
52
+ if (u.endsWith('/index.html')) u = u.slice(0, -10);
53
+ // Ensure trailing slash for directory-like paths (no extension)
54
+ if (!u.includes('.') && !u.endsWith('/')) u = u + '/';
55
+ return u;
56
+ }
57
+
58
+ /**
59
+ * Convert a source file path to the URL path it would produce,
60
+ * normalized for comparison with client URLs.
61
+ * e.g. /Users/.../docs/campaigns/abs/index.mdx → /campaigns/abs/
62
+ * @param {string} docPath - Absolute source path
63
+ * @param {string} sourceDir - Absolute source directory (with trailing slash)
64
+ * @returns {string} Normalized URL path
65
+ */
66
+ function docPathToUrl(docPath, sourceDir) {
67
+ const normalizedSource = sourceDir.endsWith('/') ? sourceDir : sourceDir + '/';
68
+ const rawUrl = '/' + docPath.replace(normalizedSource, '').replace(/\.(md|mdx|txt|yml|yaml)$/, '.html');
69
+ return normalizeUrl(rawUrl);
70
+ }
71
+
72
+ /**
73
+ * Broadcast a message to all connected WebSocket clients.
74
+ * @param {object} messageObj - Object to JSON.stringify and send
75
+ */
76
+ function broadcastMessage(messageObj) {
23
77
  if (!wss) return;
24
- const message = JSON.stringify({
25
- type: 'reload',
26
- file: changedFile,
27
- timestamp: Date.now()
28
- });
78
+ const message = JSON.stringify(messageObj);
29
79
  wss.clients.forEach(client => {
30
- if (client.readyState === 1) { // WebSocket.OPEN
80
+ if (client.readyState === 1) client.send(message);
81
+ });
82
+ }
83
+
84
+ /**
85
+ * Send a message only to clients viewing a specific set of URLs.
86
+ * Compares using normalizeUrl for consistent matching.
87
+ * @param {object} messageObj - Object to send
88
+ * @param {Set<string>} urls - Set of normalized URL paths to match against
89
+ */
90
+ function sendToClientsViewing(messageObj, urls) {
91
+ if (!wss) return;
92
+ const message = JSON.stringify(messageObj);
93
+ for (const [client, clientUrl] of clientUrls) {
94
+ if (client.readyState === 1 && clientUrl && urls.has(normalizeUrl(clientUrl))) {
31
95
  client.send(message);
32
96
  }
33
- });
34
- const clientCount = wss.clients.size;
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Broadcast a reload message to all connected clients
102
+ * @param {string} [changedFile] - Optional path of the changed file
103
+ */
104
+ function broadcastReload(changedFile = null) {
105
+ broadcastMessage({ type: 'reload', file: changedFile, timestamp: Date.now() });
106
+ const clientCount = wss ? wss.clients.size : 0;
35
107
  if (clientCount > 0) {
36
108
  console.log(`🔄 Hot reload: notified ${clientCount} browser${clientCount > 1 ? 's' : ''}`);
37
109
  }
38
110
  }
39
111
 
112
+ /**
113
+ * Send reload only to clients viewing the given URL paths.
114
+ * Other clients get 'update-no-affect' to clear their loading indicator.
115
+ * @param {Set<string>} affectedUrls - URL paths that were regenerated
116
+ * @param {string} [changedFile] - Source file that changed
117
+ */
118
+ function reloadAffectedClients(affectedUrls, changedFile = null) {
119
+ if (!wss) return;
120
+ let reloaded = 0;
121
+ let cleared = 0;
122
+ for (const [client, clientUrl] of clientUrls) {
123
+ if (client.readyState !== 1) continue;
124
+ const normalized = normalizeUrl(clientUrl);
125
+ if (normalized && affectedUrls.has(normalized)) {
126
+ client.send(JSON.stringify({ type: 'reload', file: changedFile, timestamp: Date.now() }));
127
+ reloaded++;
128
+ } else {
129
+ client.send(JSON.stringify({ type: 'update-no-affect', timestamp: Date.now() }));
130
+ cleared++;
131
+ }
132
+ }
133
+ if (reloaded > 0) {
134
+ console.log(`🔄 Hot reload: ${reloaded} affected client${reloaded > 1 ? 's' : ''} reloaded${cleared > 0 ? `, ${cleared} unaffected` : ''}`);
135
+ }
136
+ }
137
+
40
138
  /**
41
139
  * Generate the hot reload client script
42
140
  * @param {number} wsPort - WebSocket server port
@@ -53,20 +151,59 @@ function getHotReloadScript(wsPort) {
53
151
  const maxReconnectAttempts = 10;
54
152
  const reconnectDelay = 1000;
55
153
 
154
+ // Loading indicator management
155
+ let indicatorEl = null;
156
+ function getIndicator() {
157
+ if (indicatorEl) return indicatorEl;
158
+ indicatorEl = document.getElementById('ursa-update-indicator');
159
+ return indicatorEl;
160
+ }
161
+ function showIndicator(color) {
162
+ const el = getIndicator();
163
+ if (!el) return;
164
+ el.style.display = 'flex';
165
+ el.className = 'ursa-update-indicator ursa-update-' + color;
166
+ }
167
+ function hideIndicator() {
168
+ const el = getIndicator();
169
+ if (!el) return;
170
+ el.style.display = 'none';
171
+ el.className = 'ursa-update-indicator';
172
+ }
173
+
174
+ function sendUrl() {
175
+ if (ws && ws.readyState === 1) {
176
+ ws.send(JSON.stringify({ type: 'url', url: window.location.pathname }));
177
+ }
178
+ }
179
+
56
180
  function connect() {
57
181
  ws = new WebSocket(wsUrl);
58
182
 
59
183
  ws.onopen = function() {
60
184
  console.log('[Ursa] Hot reload connected');
61
185
  reconnectAttempts = 0;
186
+ sendUrl();
62
187
  };
63
188
 
64
189
  ws.onmessage = function(event) {
65
190
  try {
66
191
  const data = JSON.parse(event.data);
67
- if (data.type === 'reload') {
68
- console.log('[Ursa] Reloading page...');
69
- window.location.reload();
192
+ switch (data.type) {
193
+ case 'reload':
194
+ hideIndicator();
195
+ console.log('[Ursa] Reloading page...');
196
+ window.location.reload();
197
+ break;
198
+ case 'update-start':
199
+ showIndicator('gray');
200
+ break;
201
+ case 'update-affects-you':
202
+ showIndicator('green');
203
+ break;
204
+ case 'update-no-affect':
205
+ hideIndicator();
206
+ break;
70
207
  }
71
208
  } catch (e) {
72
209
  console.error('[Ursa] Hot reload error:', e);
@@ -89,15 +226,25 @@ function getHotReloadScript(wsPort) {
89
226
  }
90
227
 
91
228
  connect();
229
+
230
+ // Track navigation (SPA-style or hash changes)
231
+ window.addEventListener('popstate', sendUrl);
232
+ // Also re-send on page visibility change (e.g. tab switch)
233
+ document.addEventListener('visibilitychange', function() {
234
+ if (!document.hidden) sendUrl();
235
+ });
92
236
  })();
93
237
  </script>
94
238
  `;
95
239
  }
96
240
 
97
- // Debounce timer and lock for preventing concurrent regenerations
98
- let debounceTimer = null;
241
+ // Lock for preventing concurrent regenerations
99
242
  let isRegenerating = false;
100
- const DEBOUNCE_MS = 100; // Wait 100ms after last change before regenerating
243
+
244
+ // Debounce state for file change batching
245
+ const DEBOUNCE_MS = 500; // Wait 500ms of quiet before starting regeneration
246
+ let pendingChanges = []; // { evt, name, watcher: 'source'|'meta' }
247
+ let debounceTimer = null;
101
248
 
102
249
  /**
103
250
  * Copy a single CSS file to the output directory
@@ -186,6 +333,9 @@ export async function serve({
186
333
 
187
334
  console.log({ source: sourceDir, meta: metaDir, output: outputDir, port, whitelist: _whitelist, exclude: _exclude, clean: _clean });
188
335
 
336
+ // Resolve port (prompt user if occupied)
337
+ port = await resolvePort(port);
338
+
189
339
  // Ensure output directory exists and start server immediately
190
340
  await mkdir(outputDir, { recursive: true });
191
341
  serveFiles(outputDir, port);
@@ -231,171 +381,294 @@ export async function serve({
231
381
  console.log(" Source:", sourceDir, "(fast single-file mode)");
232
382
  console.log(" Meta:", metaDir, "(full rebuild)");
233
383
  console.log("\nPress Ctrl+C to stop the server\n");
234
-
235
- // Meta changes trigger full rebuild (templates, CSS, etc. affect all pages)
236
- watch(metaDir, { recursive: true, filter: /\.(js|json|css|html|md|txt|yml|yaml)$/ }, async (evt, name) => {
237
- console.log(`Meta files changed! Event: ${evt}, File: ${name}`);
238
- console.log("Full rebuild required (meta files affect all pages)...");
239
- clearWatchCache(); // Clear cache since templates/CSS may have changed
240
- try {
241
- // Use deferred images and search index for meta rebuilds too
242
- const result = await generate({ _source: sourceDir, _meta: metaDir, _output: outputDir, _whitelist, _exclude, _clean: true, _deferImages: true, _deferSearchIndex: true });
243
- console.log("HTML regeneration complete.");
244
- broadcastReload(name);
245
- if (result && result.deferredImageProcessing) {
246
- result.deferredImageProcessing.then(() => {
247
- console.log("Image preview generation complete.");
248
- }).catch(e => console.error("Image processing error:", e.message));
249
- }
250
- if (result && result.deferredSearchIndex) {
251
- result.deferredSearchIndex.then(() => {
252
- console.log("Search index generation complete.");
253
- }).catch(e => console.error("Search index error:", e.message));
254
- }
255
- } catch (error) {
256
- console.error("Error during regeneration:", error.message);
257
- }
258
- });
259
384
 
260
- // Source changes: try fast single-file regeneration first
261
- // Falls back to full rebuild for CSS, config, or if cache isn't ready
262
- watch(sourceDir, {
263
- recursive: true,
264
- filter: (f, skip) => {
265
- // Skip .ursa folder (contains hash cache that gets updated during generation)
266
- if (/[\/\\]\.ursa[\/\\]?/.test(f)) return skip;
267
- // Watch article files, config files, and static assets
268
- 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);
385
+ /**
386
+ * Queue a file change for debounced batch processing.
387
+ * Sends 'update-start' to all clients on the first change in a batch.
388
+ * Resets the 500ms debounce timer on each subsequent change.
389
+ */
390
+ function queueChange(evt, name, watcher) {
391
+ // Send 'update-start' immediately on first change in a batch
392
+ if (pendingChanges.length === 0) {
393
+ broadcastMessage({ type: 'update-start', timestamp: Date.now() });
269
394
  }
270
- }, async (evt, name) => {
271
- // Skip if we're already regenerating
395
+ pendingChanges.push({ evt, name, watcher });
396
+
397
+ // Reset debounce timer
398
+ if (debounceTimer) clearTimeout(debounceTimer);
399
+ debounceTimer = setTimeout(() => {
400
+ debounceTimer = null;
401
+ const batch = pendingChanges.splice(0);
402
+ processChangeBatch(batch, sourceDir, metaDir, outputDir, _whitelist, _exclude);
403
+ }, DEBOUNCE_MS);
404
+ }
405
+
406
+ /**
407
+ * Process a batch of accumulated file changes.
408
+ * Categorizes changes, handles immediate operations (copies), then
409
+ * regenerates affected documents with priority ordering.
410
+ */
411
+ async function processChangeBatch(batch, sourceDir, metaDir, outputDir, _whitelist, _exclude) {
272
412
  if (isRegenerating) {
273
- console.log(`⏳ Skipping ${name} (regeneration in progress)`);
274
- return;
275
- }
276
-
277
- // CSS files: just copy the file (no longer embedded in HTML)
278
- const isCssChange = name && name.endsWith('.css');
279
- // Menu/config changes need full rebuild
280
- const isMenuChange = name && (name.includes('_menu') || name.includes('menu.'));
281
- const isConfigChange = name && (name.includes('_config') || name.includes('.ursa'));
282
-
283
- if (isCssChange) {
284
- console.log(`\n🎨 CSS change detected: ${name}`);
285
- isRegenerating = true;
286
- try {
287
- const result = await copyCssFile(name, sourceDir + '/', outputDir + '/');
288
- if (result.success) {
289
- console.log(`✅ ${result.message}`);
290
- broadcastReload(name);
291
- } else {
292
- console.log(`⚠️ ${result.message}`);
293
- }
294
- } catch (error) {
295
- console.error("Error copying CSS:", error.message);
296
- } finally {
297
- isRegenerating = false;
298
- }
413
+ console.log(`⏳ Debounce batch skipped (regeneration already in progress) — ${batch.length} changes lost`);
414
+ broadcastMessage({ type: 'update-no-affect', timestamp: Date.now() });
299
415
  return;
300
416
  }
301
-
302
- // Static files (images, fonts, etc.): just copy the file
303
- const isStaticFile = name && STATIC_FILE_EXTENSIONS.test(name);
304
- if (isStaticFile) {
305
- console.log(`\n🖼️ Static file ${evt === 'remove' ? 'removed' : 'changed'}: ${name}`);
306
- isRegenerating = true;
307
- try {
417
+ isRegenerating = true;
418
+
419
+ try {
420
+ // Categorize changes
421
+ const metaChanges = batch.filter(c => c.watcher === 'meta');
422
+ const sourceChanges = batch.filter(c => c.watcher === 'source');
423
+
424
+ const cssChanges = sourceChanges.filter(c => c.name?.endsWith('.css'));
425
+ const scriptJsChanges = sourceChanges.filter(c => c.name && basename(c.name) === 'script.js');
426
+ const staticChanges = sourceChanges.filter(c => c.name && STATIC_FILE_EXTENSIONS.test(c.name));
427
+ const menuConfigChanges = sourceChanges.filter(c => {
428
+ if (!c.name) return false;
429
+ return c.name.includes('_menu') || c.name.includes('menu.') || c.name.includes('_config') || c.name.includes('.ursa');
430
+ });
431
+ const articleChanges = sourceChanges.filter(c => c.name && /\.(md|mdx|txt|yml)$/.test(c.name))
432
+ .filter(c => !menuConfigChanges.some(m => m.name === c.name)); // exclude menu files already handled
433
+ const otherSourceChanges = sourceChanges.filter(c =>
434
+ !cssChanges.includes(c) && !scriptJsChanges.includes(c) && !staticChanges.includes(c) &&
435
+ !menuConfigChanges.includes(c) && !articleChanges.includes(c)
436
+ );
437
+
438
+ const allNames = batch.map(c => c.name).filter(Boolean);
439
+ const uniqueNames = [...new Set(allNames)];
440
+ console.log(`\n📦 Processing batch: ${uniqueNames.length} file(s) changed`);
441
+ for (const n of uniqueNames) console.log(` ${n}`);
442
+
443
+ // Track whether we need a full rebuild (menu/config change, or unknown meta change)
444
+ let needsFullRebuild = false;
445
+ let fullRebuildReason = '';
446
+ // Collect all document paths that need regeneration (for selective rebuild)
447
+ const affectedDocPaths = new Set();
448
+
449
+ // --- 1) Handle static file copies (immediate, no rebuild) ---
450
+ for (const change of staticChanges) {
451
+ const { evt, name } = change;
308
452
  if (evt === 'remove') {
309
- // Delete the file from output
310
453
  const relativePath = name.replace(sourceDir, '');
311
454
  const outputPath = join(outputDir, relativePath);
312
- try {
313
- await promises.unlink(outputPath);
314
- console.log(`✅ Removed ${relativePath}`);
315
- } catch (e) {
316
- if (e.code !== 'ENOENT') {
317
- console.log(`⚠️ Error removing file: ${e.message}`);
318
- }
319
- }
455
+ try { await promises.unlink(outputPath); console.log(`🗑️ Removed static: ${relativePath}`); } catch {}
320
456
  } else {
321
457
  const result = await copyStaticFile(name, sourceDir + '/', outputDir + '/');
322
- if (result.success) {
323
- console.log(`✅ ${result.message}`);
324
- broadcastReload(name);
458
+ if (result.success) console.log(`✅ ${result.message}`);
459
+ }
460
+ }
461
+
462
+ // --- 2) Handle CSS copies + gather affected docs ---
463
+ for (const change of cssChanges) {
464
+ const result = await copyCssFile(change.name, sourceDir + '/', outputDir + '/');
465
+ if (result.success) console.log(`✅ ${result.message}`);
466
+ if (watchModeCache.isInitialized) {
467
+ const plan = dependencyTracker.getInvalidationPlan(change.name, sourceDir);
468
+ if (plan.requiresFullRebuild) {
469
+ needsFullRebuild = true;
470
+ fullRebuildReason = plan.reason;
325
471
  } else {
326
- console.log(`⚠️ ${result.message}`);
472
+ plan.affectedDocuments.forEach(d => affectedDocPaths.add(d));
327
473
  }
328
474
  }
329
- } catch (error) {
330
- console.error("Error handling static file:", error.message);
331
- } finally {
332
- isRegenerating = false;
333
475
  }
334
- return;
335
- }
336
-
337
- if (isMenuChange || isConfigChange) {
338
- console.log(`\n📦 ${isMenuChange ? 'Menu' : 'Config'} change detected: ${name}`);
339
- console.log("Full rebuild required...");
340
- clearWatchCache();
341
- isRegenerating = true;
342
- try {
343
- await generate({ _source: sourceDir, _meta: metaDir, _output: outputDir, _whitelist, _exclude, _clean: true });
344
- console.log("Regeneration complete.");
345
- broadcastReload(name);
346
- } catch (error) {
347
- console.error("Error during regeneration:", error.message);
348
- } finally {
349
- isRegenerating = false;
476
+
477
+ // --- 3) Handle script.js copies + gather affected docs ---
478
+ for (const change of scriptJsChanges) {
479
+ const relativePath = change.name.replace(sourceDir + '/', '').replace(sourceDir, '');
480
+ const outputPath = join(outputDir, relativePath);
481
+ const content = await readFile(change.name, 'utf8');
482
+ await outputFile(outputPath, content);
483
+ console.log(`✅ Copied ${relativePath}`);
484
+ if (watchModeCache.isInitialized) {
485
+ const plan = dependencyTracker.getInvalidationPlan(change.name, sourceDir);
486
+ plan.affectedDocuments.forEach(d => affectedDocPaths.add(d));
487
+ }
350
488
  }
351
- return;
352
- }
353
-
354
- // Try fast single-file regeneration for article files
355
- const isArticle = name && /\.(md|mdx|txt|yml)$/.test(name);
356
- if (isArticle) {
357
- console.log(`\n⚡ Fast regeneration: ${name}`);
358
- isRegenerating = true;
359
- try {
360
- const result = await regenerateSingleFile(name, {
361
- _source: sourceDir,
362
- _meta: metaDir,
363
- _output: outputDir
489
+
490
+ // --- 4) Handle meta changes ---
491
+ if (metaChanges.length > 0) {
492
+ console.log(`🎨 Processing ${metaChanges.length} meta change(s)`);
493
+ const pub = join(outputDir, 'public');
494
+ clearMetaBundleCache();
495
+ await copyMetaAssets(metaDir, pub);
496
+ const freshTemplates = await getTemplates(metaDir);
497
+ const bundledTemplates = await bundleMetaTemplateAssets(freshTemplates, metaDir, pub, { minify: true, sourcemap: false });
498
+ console.log('🔄 Reloaded and re-bundled meta templates');
499
+
500
+ if (watchModeCache.isInitialized) {
501
+ watchModeCache.templates = bundledTemplates;
502
+ // Check each meta change for its invalidation plan
503
+ for (const change of metaChanges) {
504
+ const plan = dependencyTracker.getMetaInvalidationPlan(change.name, metaDir);
505
+ if (plan.requiresFullRebuild) {
506
+ needsFullRebuild = true;
507
+ fullRebuildReason = plan.reason;
508
+ } else {
509
+ plan.affectedDocuments.forEach(d => affectedDocPaths.add(d));
510
+ }
511
+ }
512
+ } else {
513
+ needsFullRebuild = true;
514
+ fullRebuildReason = 'Cache not initialized';
515
+ }
516
+ }
517
+
518
+ // --- 5) Handle menu/config changes → force full rebuild ---
519
+ if (menuConfigChanges.length > 0) {
520
+ needsFullRebuild = true;
521
+ fullRebuildReason = `Menu/config change: ${menuConfigChanges.map(c => basename(c.name)).join(', ')}`;
522
+ // Delete on-disk caches to force full navigation + content rebuild
523
+ const ursaDir = join(sourceDir, '.ursa');
524
+ try { await promises.unlink(join(ursaDir, 'content-hashes.json')); } catch {}
525
+ try { await promises.unlink(join(ursaDir, 'nav-cache.json')); } catch {}
526
+ }
527
+
528
+ // --- 6) Handle article changes via fast single-file regen ---
529
+ // Deduplicate articles (same file may appear multiple times in rapid saves)
530
+ const uniqueArticles = [...new Set(articleChanges.map(c => c.name))];
531
+ for (const articlePath of uniqueArticles) {
532
+ affectedDocPaths.add(articlePath);
533
+ }
534
+
535
+ // --- 7) Handle other source changes → full rebuild ---
536
+ if (otherSourceChanges.length > 0) {
537
+ needsFullRebuild = true;
538
+ fullRebuildReason = `Non-standard source change: ${otherSourceChanges.map(c => basename(c.name || 'unknown')).join(', ')}`;
539
+ }
540
+
541
+ // --- 8) Execute rebuild ---
542
+ if (needsFullRebuild) {
543
+ console.log(`📦 Full rebuild required: ${fullRebuildReason}`);
544
+ clearWatchCache();
545
+ try {
546
+ const result = await generate({ _source: sourceDir, _meta: metaDir, _output: outputDir, _whitelist, _exclude, _deferImages: true, _deferSearchIndex: true });
547
+ console.log("HTML regeneration complete.");
548
+ if (result?.deferredImageProcessing) {
549
+ result.deferredImageProcessing.then(() => console.log("Image preview generation complete.")).catch(e => console.error("Image processing error:", e.message));
550
+ }
551
+ if (result?.deferredSearchIndex) {
552
+ result.deferredSearchIndex.then(() => console.log("Search index generation complete.")).catch(e => console.error("Search index error:", e.message));
553
+ }
554
+ // Full rebuild: reload all clients
555
+ broadcastReload(uniqueNames[0]);
556
+ } catch (genError) {
557
+ console.error(`❌ Full rebuild failed:`, genError);
558
+ console.error(genError.stack);
559
+ // Still reload — fresh content may be partially written, better than stale
560
+ broadcastReload(uniqueNames[0]);
561
+ }
562
+ } else if (affectedDocPaths.size > 0) {
563
+ // Selective rebuild with priority ordering
564
+ const docPathsArray = [...affectedDocPaths];
565
+
566
+ // Determine which URLs clients are viewing, map to source paths for priority
567
+ const viewedUrls = getClientViewedUrls().map(normalizeUrl);
568
+ const priorityPaths = [];
569
+ const affectedUrlSet = new Set();
570
+
571
+ for (const docPath of docPathsArray) {
572
+ const url = docPathToUrl(docPath, sourceDir + '/');
573
+ affectedUrlSet.add(url);
574
+ if (viewedUrls.includes(url)) {
575
+ priorityPaths.push(docPath);
576
+ }
577
+ }
578
+
579
+ console.log(`🔀 Selective rebuild: ${docPathsArray.length} docs, ${priorityPaths.length} priority`);
580
+ if (priorityPaths.length > 0) {
581
+ console.log(` Priority: ${priorityPaths.map(p => basename(p)).join(', ')}`);
582
+ }
583
+ console.log(` Client URLs: ${viewedUrls.join(', ') || '(none)'}`);
584
+ console.log(` Affected URLs: ${[...affectedUrlSet].slice(0, 5).join(', ')}${affectedUrlSet.size > 5 ? ` +${affectedUrlSet.size - 5} more` : ''}`);
585
+
586
+ // Notify clients whether the change affects them
587
+ if (affectedUrlSet.size > 0) {
588
+ sendToClientsViewing({ type: 'update-affects-you', timestamp: Date.now() }, affectedUrlSet);
589
+ }
590
+
591
+ const regenResult = await regenerateAffectedDocuments(docPathsArray, {
592
+ _source: sourceDir, _meta: metaDir, _output: outputDir,
593
+ reason: `batch: ${uniqueNames.map(n => basename(n)).join(', ')}`,
594
+ priorityPaths,
595
+ onPriorityComplete: ({ regenerated, failed, priorityDocs }) => {
596
+ if (regenerated > 0) {
597
+ // Immediately reload clients whose pages are now ready
598
+ const readyUrls = new Set(priorityDocs.map(p => docPathToUrl(p, sourceDir + '/')));
599
+ console.log(`⚡ Priority complete: ${regenerated} OK, ${failed} failed → reloading clients`);
600
+ reloadAffectedClients(readyUrls, uniqueNames[0]);
601
+ } else if (failed > 0) {
602
+ console.warn(`⚠️ Priority regen failed for all ${failed} docs — not reloading yet`);
603
+ }
604
+ },
364
605
  });
365
-
366
- if (result.success) {
367
- console.log(`✅ ${result.message}`);
368
- broadcastReload(name);
369
- return;
606
+
607
+ // After all remaining docs are done, reload any remaining affected clients
608
+ // (non-priority clients that weren't reloaded during onPriorityComplete)
609
+ const priorityUrlSet = new Set(priorityPaths.map(p => docPathToUrl(p, sourceDir + '/')));
610
+ const remainingUrls = new Set([...affectedUrlSet].filter(u => !priorityUrlSet.has(u)));
611
+ if (remainingUrls.size > 0) {
612
+ reloadAffectedClients(remainingUrls, uniqueNames[0]);
613
+ }
614
+
615
+ // If priority docs all failed, try reloading anyway now that remaining are done
616
+ if (priorityPaths.length > 0 && regenResult.regenerated > 0) {
617
+ const failedPriorityUrls = new Set();
618
+ // Check if any priority was among the failed — reload all affected as fallback
619
+ for (const pp of priorityPaths) {
620
+ failedPriorityUrls.add(docPathToUrl(pp, sourceDir + '/'));
621
+ }
622
+ // If regeneration succeeded overall, make sure all priority clients got reloaded
623
+ for (const [client, clientUrl] of clientUrls) {
624
+ if (client.readyState === 1 && clientUrl && failedPriorityUrls.has(normalizeUrl(clientUrl))) {
625
+ // Client might not have been reloaded if their specific doc failed but others succeeded
626
+ // The onPriorityComplete callback should have handled this, this is a safety net
627
+ }
628
+ }
629
+ }
630
+
631
+ // Clear indicator for clients not affected at all
632
+ for (const [client, clientUrl] of clientUrls) {
633
+ if (client.readyState === 1 && clientUrl && !affectedUrlSet.has(normalizeUrl(clientUrl))) {
634
+ client.send(JSON.stringify({ type: 'update-no-affect', timestamp: Date.now() }));
635
+ }
636
+ }
637
+ } else {
638
+ // No documents affected (e.g. static-only changes) — reload all clients
639
+ if (staticChanges.length > 0) {
640
+ broadcastReload(uniqueNames[0]);
641
+ } else {
642
+ // Nothing to do — clear indicators
643
+ broadcastMessage({ type: 'update-no-affect', timestamp: Date.now() });
370
644
  }
371
-
372
- // Fall back to full rebuild if single-file failed
373
- console.log(`⚠️ ${result.message}`);
374
- console.log("Falling back to full rebuild...");
375
- await generate({ _source: sourceDir, _meta: metaDir, _output: outputDir, _whitelist, _exclude });
376
- console.log("Regeneration complete.");
377
- broadcastReload(name);
378
- } catch (error) {
379
- console.error("Error during regeneration:", error.message);
380
- } finally {
381
- isRegenerating = false;
382
645
  }
383
- return;
384
- }
385
-
386
- // Non-article files - incremental build
387
- console.log(`\n📄 Non-article change: ${name}`);
388
- console.log("Running incremental rebuild...");
389
- isRegenerating = true;
390
- try {
391
- await generate({ _source: sourceDir, _meta: metaDir, _output: outputDir, _whitelist, _exclude });
392
- console.log("Regeneration complete.");
393
- broadcastReload(name);
394
646
  } catch (error) {
395
- console.error("Error during regeneration:", error.message);
647
+ console.error(`❌ Error during batch processing:`, error);
648
+ console.error(error.stack);
649
+ // Reload clients as fallback — stale content with a reload is better than a stuck spinner
650
+ broadcastReload();
396
651
  } finally {
397
652
  isRegenerating = false;
398
653
  }
654
+ }
655
+
656
+ // Meta changes: queue for debounced batch processing
657
+ watch(metaDir, { recursive: true, filter: /\.(js|json|css|html|md|txt|yml|yaml)$/ }, (evt, name) => {
658
+ queueChange(evt, name, 'meta');
659
+ });
660
+
661
+ // Source changes: queue for debounced batch processing
662
+ watch(sourceDir, {
663
+ recursive: true,
664
+ filter: (f, skip) => {
665
+ // Skip .ursa folder (contains hash cache that gets updated during generation)
666
+ if (/[\/\\]\.ursa[\/\\]?/.test(f)) return skip;
667
+ // Watch article files, config files, and static assets
668
+ 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);
669
+ }
670
+ }, (evt, name) => {
671
+ queueChange(evt, name, 'source');
399
672
  });
400
673
  }
401
674
 
@@ -484,8 +757,19 @@ function serveFiles(outputDir, port = 8080) {
484
757
  }
485
758
  }, 30000);
486
759
 
760
+ // Handle messages from the client (URL tracking)
761
+ ws.on('message', (data) => {
762
+ try {
763
+ const msg = JSON.parse(data.toString());
764
+ if (msg.type === 'url' && msg.url) {
765
+ clientUrls.set(ws, msg.url);
766
+ }
767
+ } catch (e) { /* ignore non-JSON messages */ }
768
+ });
769
+
487
770
  ws.on('close', () => {
488
771
  clearInterval(pingInterval);
772
+ clientUrls.delete(ws);
489
773
  });
490
774
  });
491
775