@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.
- package/CHANGELOG.md +41 -17
- package/README.md +1 -1
- package/meta/default.css +33 -0
- package/meta/templates/default-template/default.css +1268 -0
- package/meta/{default-template.html → templates/default-template/index.html} +15 -0
- package/meta/{menu.js → templates/default-template/menu.js} +1 -1
- package/meta/templates/default-template/sectionify.js +46 -0
- package/meta/{widgets.js → templates/default-template/widgets.js} +126 -0
- package/package.json +4 -2
- package/src/dev.js +73 -28
- package/src/helper/assetBundler.js +471 -0
- package/src/helper/build/autoIndex.js +24 -23
- package/src/helper/build/cacheBust.js +79 -0
- package/src/helper/build/navCache.js +4 -0
- package/src/helper/build/templates.js +176 -19
- package/src/helper/build/watchCache.js +7 -0
- package/src/helper/customMenu.js +4 -2
- package/src/helper/dependencyTracker.js +269 -0
- package/src/helper/findStyleCss.js +29 -0
- package/src/helper/portUtils.js +132 -0
- package/src/jobs/generate.js +234 -62
- package/src/serve.js +446 -162
- package/meta/character-sheet.css +0 -50
- /package/meta/{goudy_bookletter_1911-webfont.woff → shared/goudy_bookletter_1911-webfont.woff} +0 -0
- /package/meta/{character-sheet/css → templates/character-sheet-template}/character-sheet.css +0 -0
- /package/meta/{character-sheet/js → templates/character-sheet-template}/components.js +0 -0
- /package/meta/{cssui.bundle.min.css → templates/character-sheet-template/cssui.bundle.min.css} +0 -0
- /package/meta/{character-sheet-template.html → templates/character-sheet-template/index.html} +0 -0
- /package/meta/{character-sheet/js → templates/character-sheet-template}/main.js +0 -0
- /package/meta/{character-sheet/js → templates/character-sheet-template}/model.js +0 -0
- /package/meta/{search.js → templates/default-template/search.js} +0 -0
- /package/meta/{sticky.js → templates/default-template/sticky.js} +0 -0
- /package/meta/{toc-generator.js → templates/default-template/toc-generator.js} +0 -0
- /package/meta/{toc.js → templates/default-template/toc.js} +0 -0
- /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,
|
|
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
|
-
*
|
|
20
|
-
*
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
//
|
|
98
|
-
let debounceTimer = null;
|
|
241
|
+
// Lock for preventing concurrent regenerations
|
|
99
242
|
let isRegenerating = false;
|
|
100
|
-
|
|
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
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
-
|
|
271
|
-
|
|
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(`⏳
|
|
274
|
-
|
|
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
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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
|
-
|
|
324
|
-
|
|
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
|
-
|
|
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
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
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
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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(
|
|
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
|
|