@kenjura/ursa 0.85.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 +34 -0
- package/bin/ursa.js +23 -36
- package/meta/templates/default-template/default.css +5 -0
- package/package.json +2 -1
- package/src/dev.js +16 -1
- package/src/helper/__test__/dependencyTracker.test.js +157 -0
- package/src/helper/__test__/documentTemplates.test.js +354 -0
- package/src/helper/automenu.js +1 -1
- 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/helper/documentTemplates.js +454 -0
- package/src/jobs/generate.js +83 -6
- package/src/serve.js +66 -10
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,11 +74,13 @@ import {
|
|
|
74
74
|
getCustomMenuForFile,
|
|
75
75
|
getTransformedMetadata,
|
|
76
76
|
getFooter,
|
|
77
|
+
getUrsaMetadata,
|
|
77
78
|
generateAutoIndices,
|
|
78
79
|
generateAutoIndexHtmlFromSource,
|
|
79
80
|
copyMetaAssets,
|
|
80
81
|
} from "../helper/build/index.js";
|
|
81
82
|
import { getProfiler } from "../helper/build/profiler.js";
|
|
83
|
+
import { reconcileAll, isInsideTemplatesFolder } from "../helper/documentTemplates.js";
|
|
82
84
|
|
|
83
85
|
// Concurrency limiter for batch processing to avoid memory exhaustion
|
|
84
86
|
const BATCH_SIZE = parseInt(process.env.URSA_BATCH_SIZE || '50', 10);
|
|
@@ -164,6 +166,14 @@ export async function generate({
|
|
|
164
166
|
progress.logTimed(`Clean build: clearing output directory ${output}`);
|
|
165
167
|
await emptyDir(output);
|
|
166
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
|
+
}
|
|
167
177
|
}
|
|
168
178
|
|
|
169
179
|
// Phase: Scan source files
|
|
@@ -210,7 +220,7 @@ export async function generate({
|
|
|
210
220
|
|
|
211
221
|
// read all articles, process them, copy them to build
|
|
212
222
|
const articleExtensions = /\.(md|mdx|txt|yml)$/;
|
|
213
|
-
const hiddenOrSystemDirs = /[\/\\]\.(?!\.)|[\/\\]node_modules[\/\\]
|
|
223
|
+
const hiddenOrSystemDirs = /[\/\\]\.(?!\.)|[\/\\]node_modules[\/\\]|[\/\\]_templates[\/\\]|[\/\\]_templates$/; // Matches hidden folders (starting with .), node_modules, or _templates
|
|
214
224
|
const allSourceFilenamesThatAreArticles = allSourceFilenames.filter(
|
|
215
225
|
(filename) => filename.match(articleExtensions) && !filename.match(hiddenOrSystemDirs) && !isInHiddenFolder(filename)
|
|
216
226
|
);
|
|
@@ -230,6 +240,44 @@ export async function generate({
|
|
|
230
240
|
progress.logTimed(`Classified: ${allSourceFilenamesThatAreArticles.length} articles, ${allSourceFilenamesThatAreDirectories.length} dirs, ${existingHtmlFiles.size} HTML [${progress.stopTimer('Filter')}]`);
|
|
231
241
|
profiler.endPhase('Filter & classify');
|
|
232
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
|
+
|
|
247
|
+
// Phase: Document template reconciliation
|
|
248
|
+
// Must run BEFORE article processing so that any template-driven changes
|
|
249
|
+
// to source .md files are picked up during rendering.
|
|
250
|
+
profiler.startPhase('Template reconciliation');
|
|
251
|
+
progress.startTimer('Templates');
|
|
252
|
+
const templateReconciliation = await reconcileAll(
|
|
253
|
+
allSourceFilenamesThatAreArticles,
|
|
254
|
+
allSourceFilenamesUnfiltered, // templates live in _templates which is filtered out of articles
|
|
255
|
+
source
|
|
256
|
+
);
|
|
257
|
+
if (templateReconciliation.updated > 0 || templateReconciliation.conflicts > 0 || templateReconciliation.initialized > 0) {
|
|
258
|
+
progress.logTimed(
|
|
259
|
+
`š Document templates: ${templateReconciliation.initialized} initialized, ` +
|
|
260
|
+
`${templateReconciliation.updated} auto-merged, ` +
|
|
261
|
+
`${templateReconciliation.conflicts} conflicts, ` +
|
|
262
|
+
`${templateReconciliation.unchanged} unchanged, ` +
|
|
263
|
+
`${templateReconciliation.errors} errors`
|
|
264
|
+
);
|
|
265
|
+
if (templateReconciliation.conflicts > 0) {
|
|
266
|
+
console.warn(`\nā ļø Template conflicts require manual resolution:`);
|
|
267
|
+
for (const msg of templateReconciliation.messages) {
|
|
268
|
+
if (msg.includes('Conflict')) console.warn(` ${msg}`);
|
|
269
|
+
}
|
|
270
|
+
console.warn('');
|
|
271
|
+
}
|
|
272
|
+
if (templateReconciliation.errors > 0) {
|
|
273
|
+
for (const msg of templateReconciliation.messages) {
|
|
274
|
+
if (msg.includes('Error') || msg.includes('not found')) console.warn(` ā ļø ${msg}`);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
progress.logTimed(`Document templates processed [${progress.stopTimer('Templates')}]`);
|
|
279
|
+
profiler.endPhase('Template reconciliation');
|
|
280
|
+
|
|
233
281
|
// Phase: Build navigation and metadata
|
|
234
282
|
profiler.startPhase('Build navigation');
|
|
235
283
|
progress.startTimer('Navigation');
|
|
@@ -301,6 +349,9 @@ export async function generate({
|
|
|
301
349
|
|
|
302
350
|
profiler.endPhase('Build navigation');
|
|
303
351
|
|
|
352
|
+
// Build the _ursa_metadata embedded in every generated JSON file (ursa + doc repo versions)
|
|
353
|
+
const ursaMetadata = await getUrsaMetadata(_source);
|
|
354
|
+
|
|
304
355
|
// Phase: Load cache
|
|
305
356
|
profiler.startPhase('Load cache');
|
|
306
357
|
progress.startTimer('Cache');
|
|
@@ -864,6 +915,7 @@ export async function generate({
|
|
|
864
915
|
metadata: fileMeta,
|
|
865
916
|
sections,
|
|
866
917
|
transformedMetadata: jsonTransformedMeta,
|
|
918
|
+
_ursa_metadata: ursaMetadata,
|
|
867
919
|
};
|
|
868
920
|
|
|
869
921
|
// Store minimal data for directory indices, including metadata
|
|
@@ -1159,6 +1211,10 @@ export async function generate({
|
|
|
1159
1211
|
progress.log(`Saved ${contentTimestamps.size} content timestamps`);
|
|
1160
1212
|
}
|
|
1161
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
|
+
|
|
1162
1218
|
// Populate watch mode cache for fast single-file regeneration
|
|
1163
1219
|
watchModeCache.templates = templates;
|
|
1164
1220
|
watchModeCache.menu = menu;
|
|
@@ -1173,6 +1229,7 @@ export async function generate({
|
|
|
1173
1229
|
watchModeCache.allArticlePaths = [...allSourceFilenamesThatAreArticles];
|
|
1174
1230
|
watchModeCache.imageMap = imageMap;
|
|
1175
1231
|
watchModeCache.customMenus = customMenus;
|
|
1232
|
+
watchModeCache.ursaMetadata = ursaMetadata;
|
|
1176
1233
|
watchModeCache.lastFullBuild = Date.now();
|
|
1177
1234
|
watchModeCache.isInitialized = true;
|
|
1178
1235
|
const depStats = dependencyTracker.getStats();
|
|
@@ -1323,6 +1380,11 @@ export async function regenerateAffectedDocuments(documentPaths, {
|
|
|
1323
1380
|
}
|
|
1324
1381
|
}
|
|
1325
1382
|
|
|
1383
|
+
// Persist updated dependency registrations (e.g. a doc switched templates)
|
|
1384
|
+
if (regenerated > 0) {
|
|
1385
|
+
await saveDependencyTracker(resolve(_source) + "/");
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1326
1388
|
const elapsed = Date.now() - startTime;
|
|
1327
1389
|
const msg = `Regenerated ${regenerated}/${documentPaths.length} documents in ${elapsed}ms (${reason})${failed > 0 ? `, ${failed} failed` : ""}`;
|
|
1328
1390
|
console.log(`ā
${msg}`);
|
|
@@ -1364,8 +1426,8 @@ export async function regenerateSingleFile(changedFile, {
|
|
|
1364
1426
|
}
|
|
1365
1427
|
|
|
1366
1428
|
try {
|
|
1367
|
-
const { templates, menu, footer, validPaths, hashCache, cacheBustTimestamp, imageMap, customMenus } = watchModeCache;
|
|
1368
|
-
|
|
1429
|
+
const { templates, menu, footer, validPaths, hashCache, cacheBustTimestamp, imageMap, customMenus, ursaMetadata } = watchModeCache;
|
|
1430
|
+
|
|
1369
1431
|
const rawBody = await readFile(changedFile, "utf8");
|
|
1370
1432
|
const type = parse(changedFile).ext;
|
|
1371
1433
|
const ext = extname(changedFile);
|
|
@@ -1464,9 +1526,10 @@ export async function regenerateSingleFile(changedFile, {
|
|
|
1464
1526
|
// Find all CSS files up the tree and create separate link tags
|
|
1465
1527
|
// (regenerateSingleFile is used in serve mode, so separate tags per level for invalidation)
|
|
1466
1528
|
let styleLink = "";
|
|
1529
|
+
let cssPaths = [];
|
|
1467
1530
|
try {
|
|
1468
1531
|
const dirKey = (dir === "/" || dir === "") ? _source : resolve(_source, dir);
|
|
1469
|
-
|
|
1532
|
+
cssPaths = await findAllStyleCss(dirKey, _source);
|
|
1470
1533
|
if (cssPaths.length > 0) {
|
|
1471
1534
|
// Copy all CSS files to output (always copy in single-file mode to ensure up to date)
|
|
1472
1535
|
for (const cssPath of cssPaths) {
|
|
@@ -1492,9 +1555,10 @@ export async function regenerateSingleFile(changedFile, {
|
|
|
1492
1555
|
// Find all script.js files from docroot to current dir and serve as separate external tags
|
|
1493
1556
|
// (Serve mode: separate tags per level for individual invalidation)
|
|
1494
1557
|
let customScript = "";
|
|
1558
|
+
let scriptPaths = [];
|
|
1495
1559
|
try {
|
|
1496
1560
|
const dirKey = (dir === "/" || dir === "") ? _source : resolve(_source, dir);
|
|
1497
|
-
|
|
1561
|
+
scriptPaths = await findAllScriptJs(dirKey, _source);
|
|
1498
1562
|
if (scriptPaths.length > 0) {
|
|
1499
1563
|
// Copy all script files to output so they can be served
|
|
1500
1564
|
for (const scriptPath of scriptPaths) {
|
|
@@ -1508,6 +1572,18 @@ export async function regenerateSingleFile(changedFile, {
|
|
|
1508
1572
|
// ignore
|
|
1509
1573
|
}
|
|
1510
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
|
+
|
|
1511
1587
|
// Check if this file has a custom menu
|
|
1512
1588
|
const customMenuInfo = customMenus ? getCustomMenuForFile(changedFile, source, customMenus) : null;
|
|
1513
1589
|
|
|
@@ -1575,6 +1651,7 @@ export async function regenerateSingleFile(changedFile, {
|
|
|
1575
1651
|
metadata: fileMeta,
|
|
1576
1652
|
sections,
|
|
1577
1653
|
transformedMetadata,
|
|
1654
|
+
_ursa_metadata: ursaMetadata,
|
|
1578
1655
|
};
|
|
1579
1656
|
const json = JSON.stringify(jsonObject);
|
|
1580
1657
|
await outputFile(jsonOutputFilename, json);
|
package/src/serve.js
CHANGED
|
@@ -11,6 +11,7 @@ import { watchModeCache } from "./helper/build/watchCache.js";
|
|
|
11
11
|
import { dependencyTracker } from "./helper/dependencyTracker.js";
|
|
12
12
|
import { bundleMetaTemplateAssets, clearMetaBundleCache } from "./helper/assetBundler.js";
|
|
13
13
|
import { getTemplates, copyMetaAssets } from "./helper/build/templates.js";
|
|
14
|
+
import { isInsideTemplatesFolder, reconcileByTemplate } from "./helper/documentTemplates.js";
|
|
14
15
|
import { WebSocketServer } from "ws";
|
|
15
16
|
import { createServer } from "http";
|
|
16
17
|
import { resolvePort } from "./helper/portUtils.js";
|
|
@@ -270,6 +271,11 @@ const DEBOUNCE_MS = 500; // Wait 500ms of quiet before starting regeneration
|
|
|
270
271
|
let pendingChanges = []; // { evt, name, watcher: 'source'|'meta' }
|
|
271
272
|
let debounceTimer = null;
|
|
272
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
|
+
|
|
273
279
|
/**
|
|
274
280
|
* Copy a single CSS file to the output directory
|
|
275
281
|
* @param {string} cssPath - Absolute path to the CSS file
|
|
@@ -434,8 +440,10 @@ export async function serve({
|
|
|
434
440
|
*/
|
|
435
441
|
async function processChangeBatch(batch, sourceDir, metaDir, outputDir, _whitelist, _exclude) {
|
|
436
442
|
if (isRegenerating) {
|
|
437
|
-
|
|
438
|
-
|
|
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`);
|
|
439
447
|
return;
|
|
440
448
|
}
|
|
441
449
|
isRegenerating = true;
|
|
@@ -443,6 +451,7 @@ export async function serve({
|
|
|
443
451
|
try {
|
|
444
452
|
// Categorize changes
|
|
445
453
|
const metaChanges = batch.filter(c => c.watcher === 'meta');
|
|
454
|
+
const metaStaticChanges = metaChanges.filter(c => c.name && STATIC_FILE_EXTENSIONS.test(c.name));
|
|
446
455
|
const sourceChanges = batch.filter(c => c.watcher === 'source');
|
|
447
456
|
|
|
448
457
|
const cssChanges = sourceChanges.filter(c => c.name?.endsWith('.css'));
|
|
@@ -547,15 +556,34 @@ export async function serve({
|
|
|
547
556
|
if (menuConfigChanges.length > 0) {
|
|
548
557
|
needsFullRebuild = true;
|
|
549
558
|
fullRebuildReason = `Menu/config change: ${menuConfigChanges.map(c => basename(c.name)).join(', ')}`;
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// --- 5.5) Handle document template changes ---
|
|
562
|
+
// If a _templates/*.md file changed, reconcile all documents using that template
|
|
563
|
+
// and add the affected instance documents to the regeneration set.
|
|
564
|
+
const templateChanges = articleChanges.filter(c => c.name && isInsideTemplatesFolder(c.name));
|
|
565
|
+
if (templateChanges.length > 0 && watchModeCache.isInitialized) {
|
|
566
|
+
const allArticles = watchModeCache.allArticlePaths || [];
|
|
567
|
+
for (const change of templateChanges) {
|
|
568
|
+
console.log(`š Document template changed: ${basename(change.name)}`);
|
|
569
|
+
const reconcileResult = await reconcileByTemplate(change.name, allArticles, sourceDir);
|
|
570
|
+
if (reconcileResult.updated > 0 || reconcileResult.conflicts > 0) {
|
|
571
|
+
console.log(` ${reconcileResult.updated} auto-merged, ${reconcileResult.conflicts} conflicts`);
|
|
572
|
+
reconcileResult.affectedPaths.forEach(p => affectedDocPaths.add(p));
|
|
573
|
+
}
|
|
574
|
+
if (reconcileResult.conflicts > 0) {
|
|
575
|
+
for (const msg of reconcileResult.messages) {
|
|
576
|
+
if (msg.includes('Conflict')) console.warn(` ā ļø ${msg}`);
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
}
|
|
554
580
|
}
|
|
555
581
|
|
|
556
582
|
// --- 6) Handle article changes via fast single-file regen ---
|
|
557
583
|
// Deduplicate articles (same file may appear multiple times in rapid saves)
|
|
558
|
-
|
|
584
|
+
// Exclude _templates files from direct article regeneration (they aren't rendered)
|
|
585
|
+
const uniqueArticles = [...new Set(articleChanges.map(c => c.name))]
|
|
586
|
+
.filter(name => !isInsideTemplatesFolder(name));
|
|
559
587
|
for (const articlePath of uniqueArticles) {
|
|
560
588
|
affectedDocPaths.add(articlePath);
|
|
561
589
|
}
|
|
@@ -569,6 +597,13 @@ export async function serve({
|
|
|
569
597
|
// --- 8) Execute rebuild ---
|
|
570
598
|
if (needsFullRebuild) {
|
|
571
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 {}
|
|
572
607
|
clearWatchCache();
|
|
573
608
|
try {
|
|
574
609
|
const result = await generate({ _source: sourceDir, _meta: metaDir, _output: outputDir, _whitelist, _exclude, _deferImages: true, _deferSearchIndex: true });
|
|
@@ -666,7 +701,8 @@ export async function serve({
|
|
|
666
701
|
}
|
|
667
702
|
} else {
|
|
668
703
|
// No documents affected (e.g. static-only changes) ā reload all clients
|
|
669
|
-
|
|
704
|
+
// (meta static assets were re-copied to output/public in step 4)
|
|
705
|
+
if (staticChanges.length > 0 || metaStaticChanges.length > 0) {
|
|
670
706
|
broadcastReload(uniqueNames[0]);
|
|
671
707
|
} else {
|
|
672
708
|
// Nothing to do ā clear indicators
|
|
@@ -680,11 +716,20 @@ export async function serve({
|
|
|
680
716
|
broadcastReload();
|
|
681
717
|
} finally {
|
|
682
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
|
+
}
|
|
683
725
|
}
|
|
684
726
|
}
|
|
685
727
|
|
|
686
|
-
// Meta changes: queue for debounced batch processing
|
|
687
|
-
|
|
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) => {
|
|
688
733
|
queueChange(evt, name, 'meta');
|
|
689
734
|
});
|
|
690
735
|
|
|
@@ -721,6 +766,17 @@ function serveFiles(outputDir, port = 8080) {
|
|
|
721
766
|
level: 6
|
|
722
767
|
}));
|
|
723
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
|
+
|
|
724
780
|
// Middleware to inject hot reload script into HTML responses
|
|
725
781
|
app.use(async (req, res, next) => {
|
|
726
782
|
// Only intercept HTML requests
|