@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.
- package/CHANGELOG.md +26 -0
- package/README.md +72 -16
- package/bin/ursa.js +14 -1
- package/meta/templates/default-template/menu.js +18 -1
- package/meta/templates/default-template/search.js +11 -0
- package/meta/templates/default-template/widgets.js +4 -0
- package/package.json +1 -2
- package/src/dev.js +13 -23
- package/src/helper/__test__/contentHash.test.js +16 -6
- package/src/helper/assetBundler.js +93 -19
- package/src/helper/automenu.js +36 -11
- package/src/helper/build/__test__/autoIndex.test.js +2 -132
- package/src/helper/build/__test__/graph.test.js +259 -3
- package/src/helper/build/__test__/pass.test.js +553 -0
- package/src/helper/build/autoIndex.js +2 -371
- package/src/helper/build/excludeFilter.js +1 -2
- package/src/helper/build/footer.js +27 -14
- package/src/helper/build/graph.js +575 -152
- package/src/helper/build/index.js +0 -2
- package/src/helper/build/metadata.js +19 -5
- package/src/helper/build/pass.js +497 -0
- package/src/helper/build/precedence.js +174 -0
- package/src/helper/build/site.js +1270 -0
- package/src/helper/build/templates.js +1 -2
- package/src/helper/build/tracedFs.js +247 -0
- package/src/helper/contentHash.js +0 -78
- package/src/helper/customMenu.js +1 -1
- package/src/helper/fileRenderer.js +119 -111
- package/src/helper/findScriptJs.js +1 -1
- package/src/helper/findStyleCss.js +1 -1
- package/src/helper/folderConfig.js +7 -18
- package/src/helper/fullTextIndex.js +41 -29
- package/src/helper/imageProcessor.js +45 -0
- package/src/helper/linkValidator.js +118 -127
- package/src/helper/mdxRenderer.js +27 -5
- package/src/helper/menuLabels.js +30 -5
- package/src/helper/whitelistFilter.js +1 -2
- package/src/jobs/generate.js +67 -1829
- package/src/serve.js +317 -697
- package/src/helper/__test__/dependencyTracker.test.js +0 -157
- package/src/helper/build/cacheBust.js +0 -141
- package/src/helper/build/navCache.js +0 -145
- package/src/helper/build/watchCache.js +0 -33
- 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 {
|
|
5
|
-
import {
|
|
6
|
-
import
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
62
|
-
|
|
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
|
-
|
|
76
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
140
|
-
|
|
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(
|
|
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
|
-
//
|
|
172
|
+
// Tell the server which page this is, whenever that might have changed
|
|
257
173
|
window.addEventListener('popstate', sendUrl);
|
|
258
|
-
|
|
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
|
-
//
|
|
268
|
-
|
|
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
|
-
|
|
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
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
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
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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
|
-
|
|
427
|
-
|
|
428
|
-
//
|
|
429
|
-
if (
|
|
430
|
-
|
|
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
|
-
|
|
439
|
-
|
|
440
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
const
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
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
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
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
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
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
|
-
|
|
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
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
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
|
-
|
|
576
|
-
|
|
577
|
-
|
|
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
|
-
|
|
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
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
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
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
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
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
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
|
-
|
|
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
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
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
|
-
|
|
682
|
-
|
|
683
|
-
|
|
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
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
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
|
-
|
|
738
|
-
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
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
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
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
|
-
|
|
774
|
-
|
|
775
|
-
res.setHeader(
|
|
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
|
-
//
|
|
467
|
+
// Pages: one resolver shared with the reload logic (§6.2), hot reload script injected
|
|
782
468
|
app.use(async (req, res, next) => {
|
|
783
|
-
|
|
784
|
-
const
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
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
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
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
|
-
//
|
|
829
|
-
app.use(
|
|
830
|
-
|
|
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 ===
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
}
|
|
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
|
-
}
|