@kenjura/ursa 0.86.0 → 0.87.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +23 -0
- package/package.json +1 -1
- package/src/dev.js +16 -1
- package/src/helper/__test__/dependencyTracker.test.js +157 -0
- package/src/helper/build/__test__/autoIndex.test.js +95 -0
- package/src/helper/build/__test__/graph.test.js +529 -0
- package/src/helper/build/autoIndex.js +7 -1
- package/src/helper/build/graph.js +542 -0
- package/src/helper/build/index.js +1 -0
- package/src/helper/build/ursaMetadata.js +62 -0
- package/src/helper/dependencyTracker.js +116 -1
- package/src/jobs/generate.js +47 -5
- package/src/serve.js +41 -9
|
@@ -17,6 +17,16 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import { dirname, join, relative, resolve } from "path";
|
|
20
|
+
import { existsSync } from "fs";
|
|
21
|
+
import { mkdir, readFile, writeFile } from "fs/promises";
|
|
22
|
+
import { getUrsaDir } from "./contentHash.js";
|
|
23
|
+
|
|
24
|
+
const DEP_GRAPH_FILE = "dependency-graph.json";
|
|
25
|
+
const DEP_GRAPH_VERSION = 1;
|
|
26
|
+
|
|
27
|
+
// Static assets in meta (copied verbatim to output/public by copyMetaAssets);
|
|
28
|
+
// these are never embedded in document HTML, so no regeneration is needed.
|
|
29
|
+
const META_STATIC_EXTENSIONS = /\.(jpg|jpeg|png|gif|webp|svg|ico|woff|woff2|ttf|eot|pdf|mp3|mp4|webm|ogg)$/i;
|
|
20
30
|
|
|
21
31
|
export class DependencyTracker {
|
|
22
32
|
constructor() {
|
|
@@ -216,7 +226,13 @@ export class DependencyTracker {
|
|
|
216
226
|
|
|
217
227
|
// Template file changed → regenerate all documents using that template
|
|
218
228
|
if (fileName.endsWith(".html")) {
|
|
219
|
-
|
|
229
|
+
// New structure: templates/{templateName}/index.html → name is the folder;
|
|
230
|
+
// legacy flat structure: {templateName}.html at the meta root
|
|
231
|
+
const parts = relativePath.split("/");
|
|
232
|
+
const templateName =
|
|
233
|
+
parts[0] === "templates" && parts.length >= 3
|
|
234
|
+
? parts[1]
|
|
235
|
+
: fileName.replace(".html", "");
|
|
220
236
|
const affected = this.getDocumentsUsingTemplate(templateName);
|
|
221
237
|
if (affected.size > 0) {
|
|
222
238
|
return {
|
|
@@ -244,6 +260,17 @@ export class DependencyTracker {
|
|
|
244
260
|
};
|
|
245
261
|
}
|
|
246
262
|
|
|
263
|
+
// Static asset in meta (image, font, PDF, media) → copyMetaAssets already
|
|
264
|
+
// re-copied it to output/public; documents reference it by URL, so no
|
|
265
|
+
// document regeneration (and no full rebuild) is needed.
|
|
266
|
+
if (META_STATIC_EXTENSIONS.test(fileName)) {
|
|
267
|
+
return {
|
|
268
|
+
affectedDocuments: [],
|
|
269
|
+
reason: `Meta static asset copied: ${relativePath}`,
|
|
270
|
+
requiresFullRebuild: false,
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
247
274
|
// Other meta file → full rebuild to be safe
|
|
248
275
|
return {
|
|
249
276
|
affectedDocuments: [],
|
|
@@ -263,7 +290,95 @@ export class DependencyTracker {
|
|
|
263
290
|
uniqueFiles: this.fileToDocuments.size,
|
|
264
291
|
};
|
|
265
292
|
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Serialize the tracker for persistence to .ursa/dependency-graph.json.
|
|
296
|
+
* @returns {{ version: number, sourceDir: string, documents: Object<string, string[]> }}
|
|
297
|
+
*/
|
|
298
|
+
serialize() {
|
|
299
|
+
return {
|
|
300
|
+
version: DEP_GRAPH_VERSION,
|
|
301
|
+
sourceDir: this.sourceDir,
|
|
302
|
+
documents: Object.fromEntries(
|
|
303
|
+
[...this.documentToFiles.entries()].map(([doc, deps]) => [doc, [...deps]])
|
|
304
|
+
),
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Load persisted registrations, merging with the current run: documents
|
|
310
|
+
* already registered in this run keep their (fresher) edges; persisted
|
|
311
|
+
* edges only fill in documents not yet registered (e.g. hash-skipped docs).
|
|
312
|
+
* Rejects data from a different source directory or schema version.
|
|
313
|
+
* @param {object} data - Previously serialized tracker
|
|
314
|
+
* @returns {boolean} Whether the data was loaded
|
|
315
|
+
*/
|
|
316
|
+
load(data) {
|
|
317
|
+
if (!data || data.version !== DEP_GRAPH_VERSION) return false;
|
|
318
|
+
if (data.sourceDir && this.sourceDir && data.sourceDir !== this.sourceDir) return false;
|
|
319
|
+
for (const [doc, deps] of Object.entries(data.documents || {})) {
|
|
320
|
+
if (this.documentToFiles.has(doc)) continue; // live registrations win
|
|
321
|
+
if (!Array.isArray(deps)) continue;
|
|
322
|
+
for (const dep of deps) {
|
|
323
|
+
this.addDependency(doc, dep);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
return true;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Drop registrations for documents not in the given set (e.g. deleted or
|
|
331
|
+
* excluded files), so persisted state doesn't accumulate stale entries.
|
|
332
|
+
* @param {Set<string>} keepDocuments - Document paths that should survive
|
|
333
|
+
*/
|
|
334
|
+
prune(keepDocuments) {
|
|
335
|
+
for (const doc of [...this.documentToFiles.keys()]) {
|
|
336
|
+
if (!keepDocuments.has(doc)) this.clearDocument(doc);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
266
339
|
}
|
|
267
340
|
|
|
268
341
|
// Singleton instance
|
|
269
342
|
export const dependencyTracker = new DependencyTracker();
|
|
343
|
+
|
|
344
|
+
/** Path to the persisted dependency graph for a source directory. */
|
|
345
|
+
export function getDependencyGraphPath(sourceDir) {
|
|
346
|
+
return join(getUrsaDir(sourceDir), DEP_GRAPH_FILE);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Load the persisted dependency tracker state from .ursa/dependency-graph.json
|
|
351
|
+
* and merge it into the tracker (current-run registrations win).
|
|
352
|
+
* @param {string} sourceDir - Source directory root
|
|
353
|
+
* @param {DependencyTracker} [tracker] - Defaults to the singleton
|
|
354
|
+
* @returns {Promise<boolean>} Whether a valid graph was loaded
|
|
355
|
+
*/
|
|
356
|
+
export async function loadDependencyTracker(sourceDir, tracker = dependencyTracker) {
|
|
357
|
+
const path = getDependencyGraphPath(sourceDir);
|
|
358
|
+
try {
|
|
359
|
+
if (!existsSync(path)) return false;
|
|
360
|
+
const data = JSON.parse(await readFile(path, "utf8"));
|
|
361
|
+
return tracker.load(data);
|
|
362
|
+
} catch (e) {
|
|
363
|
+
console.warn(`Could not load dependency graph: ${e.message}`);
|
|
364
|
+
return false;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Persist the dependency tracker to .ursa/dependency-graph.json so that
|
|
370
|
+
* hash-skipped documents keep their edges across warm starts.
|
|
371
|
+
* @param {string} sourceDir - Source directory root
|
|
372
|
+
* @param {DependencyTracker} [tracker] - Defaults to the singleton
|
|
373
|
+
* @returns {Promise<boolean>} Whether the graph was saved
|
|
374
|
+
*/
|
|
375
|
+
export async function saveDependencyTracker(sourceDir, tracker = dependencyTracker) {
|
|
376
|
+
try {
|
|
377
|
+
await mkdir(getUrsaDir(sourceDir), { recursive: true });
|
|
378
|
+
await writeFile(getDependencyGraphPath(sourceDir), JSON.stringify(tracker.serialize()));
|
|
379
|
+
return true;
|
|
380
|
+
} catch (e) {
|
|
381
|
+
console.warn(`Could not save dependency graph: ${e.message}`);
|
|
382
|
+
return false;
|
|
383
|
+
}
|
|
384
|
+
}
|
package/src/jobs/generate.js
CHANGED
|
@@ -32,7 +32,7 @@ import { findStyleCss, findAllStyleCss } from "../helper/findStyleCss.js";
|
|
|
32
32
|
import { findScriptJs, findAllScriptJs } from "../helper/findScriptJs.js";
|
|
33
33
|
import { bundleMetaTemplateAssets, bundleDocumentCss, bundleDocumentJs, clearMetaBundleCache, generateSeparateCssTags, generateSeparateJsTags } from "../helper/assetBundler.js";
|
|
34
34
|
import { buildFullTextIndex, buildIncrementalIndex, loadIndexCache, saveIndexCache } from "../helper/fullTextIndex.js";
|
|
35
|
-
import { dependencyTracker } from "../helper/dependencyTracker.js";
|
|
35
|
+
import { dependencyTracker, loadDependencyTracker, saveDependencyTracker } from "../helper/dependencyTracker.js";
|
|
36
36
|
import { CacheBustHashMap } from "../helper/build/cacheBust.js";
|
|
37
37
|
import { copy as copyDir, emptyDir, outputFile, remove } from "fs-extra";
|
|
38
38
|
import { basename, dirname, extname, join, parse, resolve } from "path";
|
|
@@ -74,6 +74,7 @@ import {
|
|
|
74
74
|
getCustomMenuForFile,
|
|
75
75
|
getTransformedMetadata,
|
|
76
76
|
getFooter,
|
|
77
|
+
getUrsaMetadata,
|
|
77
78
|
generateAutoIndices,
|
|
78
79
|
generateAutoIndexHtmlFromSource,
|
|
79
80
|
copyMetaAssets,
|
|
@@ -165,6 +166,14 @@ export async function generate({
|
|
|
165
166
|
progress.logTimed(`Clean build: clearing output directory ${output}`);
|
|
166
167
|
await emptyDir(output);
|
|
167
168
|
progress.logTimed(`Clean complete [${progress.stopTimer('Clean')}]`);
|
|
169
|
+
} else {
|
|
170
|
+
// Warm start: reload persisted dependency registrations so hash-skipped
|
|
171
|
+
// documents keep their edges (current-run registrations take precedence)
|
|
172
|
+
const loaded = await loadDependencyTracker(source);
|
|
173
|
+
if (loaded) {
|
|
174
|
+
const stats = dependencyTracker.getStats();
|
|
175
|
+
progress.logTimed(`Dependency graph loaded: ${stats.totalDocuments} documents, ${stats.uniqueFiles} dependencies`);
|
|
176
|
+
}
|
|
168
177
|
}
|
|
169
178
|
|
|
170
179
|
// Phase: Scan source files
|
|
@@ -231,6 +240,10 @@ export async function generate({
|
|
|
231
240
|
progress.logTimed(`Classified: ${allSourceFilenamesThatAreArticles.length} articles, ${allSourceFilenamesThatAreDirectories.length} dirs, ${existingHtmlFiles.size} HTML [${progress.stopTimer('Filter')}]`);
|
|
232
241
|
profiler.endPhase('Filter & classify');
|
|
233
242
|
|
|
243
|
+
// Drop persisted dependency registrations for documents that no longer
|
|
244
|
+
// exist (or are excluded), so stale entries don't accumulate across runs
|
|
245
|
+
dependencyTracker.prune(new Set(allSourceFilenamesThatAreArticles));
|
|
246
|
+
|
|
234
247
|
// Phase: Document template reconciliation
|
|
235
248
|
// Must run BEFORE article processing so that any template-driven changes
|
|
236
249
|
// to source .md files are picked up during rendering.
|
|
@@ -336,6 +349,9 @@ export async function generate({
|
|
|
336
349
|
|
|
337
350
|
profiler.endPhase('Build navigation');
|
|
338
351
|
|
|
352
|
+
// Build the _ursa_metadata embedded in every generated JSON file (ursa + doc repo versions)
|
|
353
|
+
const ursaMetadata = await getUrsaMetadata(_source);
|
|
354
|
+
|
|
339
355
|
// Phase: Load cache
|
|
340
356
|
profiler.startPhase('Load cache');
|
|
341
357
|
progress.startTimer('Cache');
|
|
@@ -899,6 +915,7 @@ export async function generate({
|
|
|
899
915
|
metadata: fileMeta,
|
|
900
916
|
sections,
|
|
901
917
|
transformedMetadata: jsonTransformedMeta,
|
|
918
|
+
_ursa_metadata: ursaMetadata,
|
|
902
919
|
};
|
|
903
920
|
|
|
904
921
|
// Store minimal data for directory indices, including metadata
|
|
@@ -1194,6 +1211,10 @@ export async function generate({
|
|
|
1194
1211
|
progress.log(`Saved ${contentTimestamps.size} content timestamps`);
|
|
1195
1212
|
}
|
|
1196
1213
|
|
|
1214
|
+
// Persist the dependency tracker so hash-skipped documents keep their
|
|
1215
|
+
// edges on the next warm start (invalidation plans stay accurate)
|
|
1216
|
+
await saveDependencyTracker(source);
|
|
1217
|
+
|
|
1197
1218
|
// Populate watch mode cache for fast single-file regeneration
|
|
1198
1219
|
watchModeCache.templates = templates;
|
|
1199
1220
|
watchModeCache.menu = menu;
|
|
@@ -1208,6 +1229,7 @@ export async function generate({
|
|
|
1208
1229
|
watchModeCache.allArticlePaths = [...allSourceFilenamesThatAreArticles];
|
|
1209
1230
|
watchModeCache.imageMap = imageMap;
|
|
1210
1231
|
watchModeCache.customMenus = customMenus;
|
|
1232
|
+
watchModeCache.ursaMetadata = ursaMetadata;
|
|
1211
1233
|
watchModeCache.lastFullBuild = Date.now();
|
|
1212
1234
|
watchModeCache.isInitialized = true;
|
|
1213
1235
|
const depStats = dependencyTracker.getStats();
|
|
@@ -1358,6 +1380,11 @@ export async function regenerateAffectedDocuments(documentPaths, {
|
|
|
1358
1380
|
}
|
|
1359
1381
|
}
|
|
1360
1382
|
|
|
1383
|
+
// Persist updated dependency registrations (e.g. a doc switched templates)
|
|
1384
|
+
if (regenerated > 0) {
|
|
1385
|
+
await saveDependencyTracker(resolve(_source) + "/");
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1361
1388
|
const elapsed = Date.now() - startTime;
|
|
1362
1389
|
const msg = `Regenerated ${regenerated}/${documentPaths.length} documents in ${elapsed}ms (${reason})${failed > 0 ? `, ${failed} failed` : ""}`;
|
|
1363
1390
|
console.log(`✅ ${msg}`);
|
|
@@ -1399,8 +1426,8 @@ export async function regenerateSingleFile(changedFile, {
|
|
|
1399
1426
|
}
|
|
1400
1427
|
|
|
1401
1428
|
try {
|
|
1402
|
-
const { templates, menu, footer, validPaths, hashCache, cacheBustTimestamp, imageMap, customMenus } = watchModeCache;
|
|
1403
|
-
|
|
1429
|
+
const { templates, menu, footer, validPaths, hashCache, cacheBustTimestamp, imageMap, customMenus, ursaMetadata } = watchModeCache;
|
|
1430
|
+
|
|
1404
1431
|
const rawBody = await readFile(changedFile, "utf8");
|
|
1405
1432
|
const type = parse(changedFile).ext;
|
|
1406
1433
|
const ext = extname(changedFile);
|
|
@@ -1499,9 +1526,10 @@ export async function regenerateSingleFile(changedFile, {
|
|
|
1499
1526
|
// Find all CSS files up the tree and create separate link tags
|
|
1500
1527
|
// (regenerateSingleFile is used in serve mode, so separate tags per level for invalidation)
|
|
1501
1528
|
let styleLink = "";
|
|
1529
|
+
let cssPaths = [];
|
|
1502
1530
|
try {
|
|
1503
1531
|
const dirKey = (dir === "/" || dir === "") ? _source : resolve(_source, dir);
|
|
1504
|
-
|
|
1532
|
+
cssPaths = await findAllStyleCss(dirKey, _source);
|
|
1505
1533
|
if (cssPaths.length > 0) {
|
|
1506
1534
|
// Copy all CSS files to output (always copy in single-file mode to ensure up to date)
|
|
1507
1535
|
for (const cssPath of cssPaths) {
|
|
@@ -1527,9 +1555,10 @@ export async function regenerateSingleFile(changedFile, {
|
|
|
1527
1555
|
// Find all script.js files from docroot to current dir and serve as separate external tags
|
|
1528
1556
|
// (Serve mode: separate tags per level for individual invalidation)
|
|
1529
1557
|
let customScript = "";
|
|
1558
|
+
let scriptPaths = [];
|
|
1530
1559
|
try {
|
|
1531
1560
|
const dirKey = (dir === "/" || dir === "") ? _source : resolve(_source, dir);
|
|
1532
|
-
|
|
1561
|
+
scriptPaths = await findAllScriptJs(dirKey, _source);
|
|
1533
1562
|
if (scriptPaths.length > 0) {
|
|
1534
1563
|
// Copy all script files to output so they can be served
|
|
1535
1564
|
for (const scriptPath of scriptPaths) {
|
|
@@ -1543,6 +1572,18 @@ export async function regenerateSingleFile(changedFile, {
|
|
|
1543
1572
|
// ignore
|
|
1544
1573
|
}
|
|
1545
1574
|
|
|
1575
|
+
// Register this document's dependencies (template, inherited CSS/JS) so
|
|
1576
|
+
// invalidation plans stay accurate after frontmatter/template changes.
|
|
1577
|
+
// Persisted to .ursa/dependency-graph.json by regenerateAffectedDocuments.
|
|
1578
|
+
const usedTemplateName = (requestedTemplateName && templates[requestedTemplateName])
|
|
1579
|
+
? requestedTemplateName
|
|
1580
|
+
: DEFAULT_TEMPLATE_NAME;
|
|
1581
|
+
dependencyTracker.registerDocument(changedFile, {
|
|
1582
|
+
templateName: usedTemplateName,
|
|
1583
|
+
cssPaths,
|
|
1584
|
+
scriptPaths,
|
|
1585
|
+
});
|
|
1586
|
+
|
|
1546
1587
|
// Check if this file has a custom menu
|
|
1547
1588
|
const customMenuInfo = customMenus ? getCustomMenuForFile(changedFile, source, customMenus) : null;
|
|
1548
1589
|
|
|
@@ -1610,6 +1651,7 @@ export async function regenerateSingleFile(changedFile, {
|
|
|
1610
1651
|
metadata: fileMeta,
|
|
1611
1652
|
sections,
|
|
1612
1653
|
transformedMetadata,
|
|
1654
|
+
_ursa_metadata: ursaMetadata,
|
|
1613
1655
|
};
|
|
1614
1656
|
const json = JSON.stringify(jsonObject);
|
|
1615
1657
|
await outputFile(jsonOutputFilename, json);
|
package/src/serve.js
CHANGED
|
@@ -271,6 +271,11 @@ const DEBOUNCE_MS = 500; // Wait 500ms of quiet before starting regeneration
|
|
|
271
271
|
let pendingChanges = []; // { evt, name, watcher: 'source'|'meta' }
|
|
272
272
|
let debounceTimer = null;
|
|
273
273
|
|
|
274
|
+
// Changes that arrived while a regeneration pass was in flight.
|
|
275
|
+
// They are processed as the next batch when the current pass finishes —
|
|
276
|
+
// never dropped (passes run sequentially, single-writer).
|
|
277
|
+
let queuedDuringRegeneration = [];
|
|
278
|
+
|
|
274
279
|
/**
|
|
275
280
|
* Copy a single CSS file to the output directory
|
|
276
281
|
* @param {string} cssPath - Absolute path to the CSS file
|
|
@@ -435,8 +440,10 @@ export async function serve({
|
|
|
435
440
|
*/
|
|
436
441
|
async function processChangeBatch(batch, sourceDir, metaDir, outputDir, _whitelist, _exclude) {
|
|
437
442
|
if (isRegenerating) {
|
|
438
|
-
|
|
439
|
-
|
|
443
|
+
// Never drop changes: accumulate them and process when the current
|
|
444
|
+
// pass finishes (clients keep their update-start indicator until then)
|
|
445
|
+
queuedDuringRegeneration.push(...batch);
|
|
446
|
+
console.log(`⏳ Regeneration in progress — queued ${batch.length} change(s) for the next pass`);
|
|
440
447
|
return;
|
|
441
448
|
}
|
|
442
449
|
isRegenerating = true;
|
|
@@ -444,6 +451,7 @@ export async function serve({
|
|
|
444
451
|
try {
|
|
445
452
|
// Categorize changes
|
|
446
453
|
const metaChanges = batch.filter(c => c.watcher === 'meta');
|
|
454
|
+
const metaStaticChanges = metaChanges.filter(c => c.name && STATIC_FILE_EXTENSIONS.test(c.name));
|
|
447
455
|
const sourceChanges = batch.filter(c => c.watcher === 'source');
|
|
448
456
|
|
|
449
457
|
const cssChanges = sourceChanges.filter(c => c.name?.endsWith('.css'));
|
|
@@ -548,10 +556,6 @@ export async function serve({
|
|
|
548
556
|
if (menuConfigChanges.length > 0) {
|
|
549
557
|
needsFullRebuild = true;
|
|
550
558
|
fullRebuildReason = `Menu/config change: ${menuConfigChanges.map(c => basename(c.name)).join(', ')}`;
|
|
551
|
-
// Delete on-disk caches to force full navigation + content rebuild
|
|
552
|
-
const ursaDir = join(sourceDir, '.ursa');
|
|
553
|
-
try { await promises.unlink(join(ursaDir, 'content-hashes.json')); } catch {}
|
|
554
|
-
try { await promises.unlink(join(ursaDir, 'nav-cache.json')); } catch {}
|
|
555
559
|
}
|
|
556
560
|
|
|
557
561
|
// --- 5.5) Handle document template changes ---
|
|
@@ -593,6 +597,13 @@ export async function serve({
|
|
|
593
597
|
// --- 8) Execute rebuild ---
|
|
594
598
|
if (needsFullRebuild) {
|
|
595
599
|
console.log(`📦 Full rebuild required: ${fullRebuildReason}`);
|
|
600
|
+
// Delete on-disk caches on EVERY full-rebuild path (not just menu/config):
|
|
601
|
+
// the content-hash skip only looks at article markdown, so without this a
|
|
602
|
+
// rebuild after a template/meta change would skip every unchanged article
|
|
603
|
+
// and leave stale HTML (see docs/changes/serve-logic.md, root cause #2)
|
|
604
|
+
const ursaDir = join(sourceDir, '.ursa');
|
|
605
|
+
try { await promises.unlink(join(ursaDir, 'content-hashes.json')); } catch {}
|
|
606
|
+
try { await promises.unlink(join(ursaDir, 'nav-cache.json')); } catch {}
|
|
596
607
|
clearWatchCache();
|
|
597
608
|
try {
|
|
598
609
|
const result = await generate({ _source: sourceDir, _meta: metaDir, _output: outputDir, _whitelist, _exclude, _deferImages: true, _deferSearchIndex: true });
|
|
@@ -690,7 +701,8 @@ export async function serve({
|
|
|
690
701
|
}
|
|
691
702
|
} else {
|
|
692
703
|
// No documents affected (e.g. static-only changes) — reload all clients
|
|
693
|
-
|
|
704
|
+
// (meta static assets were re-copied to output/public in step 4)
|
|
705
|
+
if (staticChanges.length > 0 || metaStaticChanges.length > 0) {
|
|
694
706
|
broadcastReload(uniqueNames[0]);
|
|
695
707
|
} else {
|
|
696
708
|
// Nothing to do — clear indicators
|
|
@@ -704,11 +716,20 @@ export async function serve({
|
|
|
704
716
|
broadcastReload();
|
|
705
717
|
} finally {
|
|
706
718
|
isRegenerating = false;
|
|
719
|
+
// Process changes that arrived during this pass (sequentially, never dropped)
|
|
720
|
+
if (queuedDuringRegeneration.length > 0) {
|
|
721
|
+
const nextBatch = queuedDuringRegeneration.splice(0);
|
|
722
|
+
console.log(`▶️ Processing ${nextBatch.length} change(s) queued during the last pass`);
|
|
723
|
+
setImmediate(() => processChangeBatch(nextBatch, sourceDir, metaDir, outputDir, _whitelist, _exclude));
|
|
724
|
+
}
|
|
707
725
|
}
|
|
708
726
|
}
|
|
709
727
|
|
|
710
|
-
// Meta changes: queue for debounced batch processing
|
|
711
|
-
|
|
728
|
+
// Meta changes: queue for debounced batch processing.
|
|
729
|
+
// Includes static asset extensions (images, fonts, media, PDFs) so that
|
|
730
|
+
// replacing e.g. a PNG or font in meta/ re-runs copyMetaAssets + re-bundling
|
|
731
|
+
// instead of being invisible to the watcher.
|
|
732
|
+
watch(metaDir, { recursive: true, filter: /\.(js|json|css|html|md|txt|yml|yaml|jpg|jpeg|png|gif|webp|svg|ico|woff|woff2|ttf|eot|pdf|mp3|mp4|webm|ogg)$/i }, (evt, name) => {
|
|
712
733
|
queueChange(evt, name, 'meta');
|
|
713
734
|
});
|
|
714
735
|
|
|
@@ -745,6 +766,17 @@ function serveFiles(outputDir, port = 8080) {
|
|
|
745
766
|
level: 6
|
|
746
767
|
}));
|
|
747
768
|
|
|
769
|
+
// Add ursa-version and doc-version headers to all JSON responses
|
|
770
|
+
// (per-document JSON, directory index arrays, and public/*.json index files)
|
|
771
|
+
app.use((req, res, next) => {
|
|
772
|
+
if (req.path.endsWith('.json')) {
|
|
773
|
+
const meta = watchModeCache.ursaMetadata || {};
|
|
774
|
+
res.setHeader('X-ursa-version', meta.ursaVersion || 'unknown');
|
|
775
|
+
res.setHeader('X-doc-version', meta.docVersion || 'unknown');
|
|
776
|
+
}
|
|
777
|
+
next();
|
|
778
|
+
});
|
|
779
|
+
|
|
748
780
|
// Middleware to inject hot reload script into HTML responses
|
|
749
781
|
app.use(async (req, res, next) => {
|
|
750
782
|
// Only intercept HTML requests
|