@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.
@@ -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
- const templateName = fileName.replace(".html", "");
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
+ }
@@ -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
- const cssPaths = await findAllStyleCss(dirKey, _source);
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
- const scriptPaths = await findAllScriptJs(dirKey, _source);
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
- console.log(`⏳ Debounce batch skipped (regeneration already in progress) ${batch.length} changes lost`);
439
- 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`);
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
- 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) {
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
- 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) => {
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