@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.
@@ -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[\/\\]/; // Matches hidden folders (starting with .) or 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
- const cssPaths = await findAllStyleCss(dirKey, _source);
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
- const scriptPaths = await findAllScriptJs(dirKey, _source);
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
- console.log(`ā³ Debounce batch skipped (regeneration already in progress) — ${batch.length} changes lost`);
438
- broadcastMessage({ type: 'update-no-affect', timestamp: Date.now() });
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
- // Delete on-disk caches to force full navigation + content rebuild
551
- const ursaDir = join(sourceDir, '.ursa');
552
- try { await promises.unlink(join(ursaDir, 'content-hashes.json')); } catch {}
553
- try { await promises.unlink(join(ursaDir, 'nav-cache.json')); } catch {}
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
- const uniqueArticles = [...new Set(articleChanges.map(c => c.name))];
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
- if (staticChanges.length > 0) {
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
- watch(metaDir, { recursive: true, filter: /\.(js|json|css|html|md|txt|yml|yaml)$/ }, (evt, name) => {
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