@kenjura/ursa 0.90.0 → 0.93.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.
@@ -5,10 +5,18 @@ import { basename, dirname, extname, join } from "path";
5
5
  import { outputFile } from "fs-extra";
6
6
  import { findStyleCss, findAllStyleCss } from "../findStyleCss.js";
7
7
  import { findAllScriptJs } from "../findScriptJs.js";
8
- import { toTitleCase } from "./titleCase.js";
9
8
  import { addTimestampToHtmlStaticRefs } from "./cacheBust.js";
10
9
  import { isMetadataOnly, extractMetadata, getAutoIndexConfig } from "../metadataExtractor.js";
11
10
  import { getCustomMenuForFile } from "./menu.js";
11
+ import { getFolderConfig } from "../folderConfig.js";
12
+ import {
13
+ toDisplayName,
14
+ getFolderLabel,
15
+ getFolderSortKey,
16
+ getMenuSortAsFromFile,
17
+ getFileLabel,
18
+ findSourceDocument,
19
+ } from "../menuLabels.js";
12
20
  import { generateBreadcrumbs } from "../breadcrumbs.js";
13
21
  import { bundleDocumentCss, bundleDocumentJs } from "../assetBundler.js";
14
22
 
@@ -40,6 +48,44 @@ async function directoryHasDocuments(dir, extensions) {
40
48
  return false;
41
49
  }
42
50
 
51
+ /**
52
+ * Resolve the label and sort key for one auto-index entry, using the same
53
+ * rules as the site-wide automenu: `menu-label` frontmatter wins, then
54
+ * config.json `label` for folders, then the prettified file/folder name.
55
+ *
56
+ * @param {boolean} isDir - Whether the entry is a directory
57
+ * @param {string} baseName - Name without extension
58
+ * @param {string|null} sourceDir - Source directory holding this entry, if known
59
+ * @returns {{label: string, sortKey: string}}
60
+ */
61
+ function resolveEntryNaming(isDir, baseName, sourceDir) {
62
+ if (!sourceDir) {
63
+ return { label: toDisplayName(baseName), sortKey: baseName };
64
+ }
65
+ if (isDir) {
66
+ const childDir = join(sourceDir, baseName);
67
+ return {
68
+ label: getFolderLabel(childDir, getFolderConfig(childDir), baseName),
69
+ sortKey: getFolderSortKey(childDir) || baseName,
70
+ };
71
+ }
72
+ const sourceFile = findSourceDocument(sourceDir, baseName);
73
+ return {
74
+ label: getFileLabel(sourceFile, baseName),
75
+ sortKey: (sourceFile && getMenuSortAsFromFile(sourceFile)) || baseName,
76
+ };
77
+ }
78
+
79
+ /**
80
+ * Sort auto-index entries the way the automenu does: folders first, then by
81
+ * sort key (case-insensitive).
82
+ */
83
+ function compareEntries(a, b) {
84
+ if (a.isDir && !b.isDir) return -1;
85
+ if (!a.isDir && b.isDir) return 1;
86
+ return a.sortKey.toLowerCase().localeCompare(b.sortKey.toLowerCase());
87
+ }
88
+
43
89
  /**
44
90
  * Generate auto-index HTML content for a directory from the OUTPUT folder
45
91
  * (used by fallback auto-index generation after all files are generated)
@@ -47,9 +93,12 @@ async function directoryHasDocuments(dir, extensions) {
47
93
  * @param {number} depth - How deep to recurse (1 = current level only, 2 = current + children, etc.)
48
94
  * @param {number} [currentDepth=0] - Current recursion depth (internal use)
49
95
  * @param {string} [pathPrefix=''] - Path prefix for generating correct hrefs (internal use)
96
+ * @param {string|null} [sourceDir=null] - Matching source directory, so `menu-label`
97
+ * frontmatter and config.json labels can be honored. Without it, entries fall
98
+ * back to their prettified file names.
50
99
  * @returns {Promise<string>} HTML content for the auto-index
51
100
  */
52
- export async function generateAutoIndexHtml(dir, depth = 1, currentDepth = 0, pathPrefix = '') {
101
+ export async function generateAutoIndexHtml(dir, depth = 1, currentDepth = 0, pathPrefix = '', sourceDir = null) {
53
102
  try {
54
103
  const children = await readdir(dir, { withFileTypes: true });
55
104
 
@@ -65,12 +114,12 @@ export async function generateAutoIndexHtml(dir, depth = 1, currentDepth = 0, pa
65
114
  // Include directories and html files
66
115
  return child.isDirectory() || child.name.endsWith('.html');
67
116
  })
68
- .sort((a, b) => {
69
- // Directories first, then files, alphabetically within each group
70
- if (a.isDirectory() && !b.isDirectory()) return -1;
71
- if (!a.isDirectory() && b.isDirectory()) return 1;
72
- return a.name.localeCompare(b.name);
73
- });
117
+ .map(child => {
118
+ const isDir = child.isDirectory();
119
+ const baseName = isDir ? child.name : child.name.replace(/\.html$/, '');
120
+ return { child, isDir, baseName, ...resolveEntryNaming(isDir, baseName, sourceDir) };
121
+ })
122
+ .sort(compareEntries);
74
123
 
75
124
  if (filteredChildren.length === 0) {
76
125
  return '';
@@ -78,26 +127,26 @@ export async function generateAutoIndexHtml(dir, depth = 1, currentDepth = 0, pa
78
127
 
79
128
  const items = [];
80
129
 
81
- for (const child of filteredChildren) {
82
- const isDir = child.isDirectory();
130
+ for (const { child, isDir, label } of filteredChildren) {
83
131
  // Skip directories that contain no documents
84
132
  if (isDir) {
85
133
  const childDir = join(dir, child.name);
86
134
  if (!await directoryHasDocuments(childDir, OUTPUT_DOC_EXTENSIONS)) continue;
87
135
  }
88
- const name = isDir ? child.name : child.name.replace('.html', '');
89
136
  // Use pathPrefix to ensure hrefs are correct relative to the document root
90
137
  const childPath = pathPrefix ? `${pathPrefix}/${child.name}` : child.name;
91
138
  const href = isDir ? `${childPath}/index.html` : (pathPrefix ? `${pathPrefix}/${child.name}` : child.name);
92
- const displayName = toTitleCase(name);
93
139
  const icon = isDir ? '📁' : '📄';
94
140
 
95
- let itemHtml = `<li>${icon} <a href="${href}">${displayName}</a>`;
141
+ let itemHtml = `<li>${icon} <a href="${href}">${label}</a>`;
96
142
 
97
143
  // If this is a directory and we need to go deeper, recurse
98
144
  if (isDir && currentDepth + 1 < depth) {
99
145
  const childDir = join(dir, child.name);
100
- const childHtml = await generateAutoIndexHtml(childDir, depth, currentDepth + 1, childPath);
146
+ const childHtml = await generateAutoIndexHtml(
147
+ childDir, depth, currentDepth + 1, childPath,
148
+ sourceDir ? join(sourceDir, child.name) : null
149
+ );
101
150
  if (childHtml) {
102
151
  itemHtml += `\n${childHtml}`;
103
152
  }
@@ -140,12 +189,12 @@ export async function generateAutoIndexHtmlFromSource(sourceDir, depth = 1, curr
140
189
  // Include directories and article files (md, mdx, txt, yml, html)
141
190
  return child.isDirectory() || child.name.match(/\.(md|mdx|txt|yml|html)$/i);
142
191
  })
143
- .sort((a, b) => {
144
- // Directories first, then files, alphabetically within each group
145
- if (a.isDirectory() && !b.isDirectory()) return -1;
146
- if (!a.isDirectory() && b.isDirectory()) return 1;
147
- return a.name.localeCompare(b.name);
148
- });
192
+ .map(child => {
193
+ const isDir = child.isDirectory();
194
+ const baseName = isDir ? child.name : basename(child.name, extname(child.name));
195
+ return { child, isDir, baseName, ...resolveEntryNaming(isDir, baseName, sourceDir) };
196
+ })
197
+ .sort(compareEntries);
149
198
 
150
199
  if (filteredChildren.length === 0) {
151
200
  return '';
@@ -153,24 +202,19 @@ export async function generateAutoIndexHtmlFromSource(sourceDir, depth = 1, curr
153
202
 
154
203
  const items = [];
155
204
 
156
- for (const child of filteredChildren) {
157
- const isDir = child.isDirectory();
205
+ for (const { child, isDir, baseName, label } of filteredChildren) {
158
206
  // Skip directories that contain no documents
159
207
  if (isDir) {
160
208
  const childDir = join(sourceDir, child.name);
161
209
  if (!await directoryHasDocuments(childDir, SOURCE_DOC_EXTENSIONS)) continue;
162
210
  }
163
- // Get name without extension for display
164
- const ext = isDir ? '' : extname(child.name);
165
- const nameWithoutExt = isDir ? child.name : basename(child.name, ext);
166
211
  // Generate href - directories link to folder/index.html, files convert to .html
167
212
  // Use pathPrefix to ensure hrefs are correct relative to the document root
168
213
  const childPath = pathPrefix ? `${pathPrefix}/${child.name}` : child.name;
169
- const href = isDir ? `${childPath}/index.html` : `${pathPrefix ? pathPrefix + '/' : ''}${nameWithoutExt}.html`;
170
- const displayName = toTitleCase(nameWithoutExt);
214
+ const href = isDir ? `${childPath}/index.html` : `${pathPrefix ? pathPrefix + '/' : ''}${baseName}.html`;
171
215
  const icon = isDir ? '📁' : '📄';
172
216
 
173
- let itemHtml = `<li>${icon} <a href="${href}">${displayName}</a>`;
217
+ let itemHtml = `<li>${icon} <a href="${href}">${label}</a>`;
174
218
 
175
219
  // If this is a directory and we need to go deeper, recurse
176
220
  if (isDir && currentDepth + 1 < depth) {
@@ -313,28 +357,32 @@ export async function generateAutoIndices(output, directories, source, templates
313
357
  const children = await readdir(dir, { withFileTypes: true });
314
358
 
315
359
  // Filter to only include relevant files and folders
316
- const filteredItems = children.filter(child => {
317
- // Skip hidden files and index alternates we just checked
318
- if (child.name.startsWith('.')) return false;
319
- if (child.name === 'index.html') return false;
320
- // Include directories and html files
321
- return child.isDirectory() || child.name.endsWith('.html');
322
- });
360
+ const filteredItems = children
361
+ .filter(child => {
362
+ // Skip hidden files and index alternates we just checked
363
+ if (child.name.startsWith('.')) return false;
364
+ if (child.name === 'index.html') return false;
365
+ // Include directories and html files
366
+ return child.isDirectory() || child.name.endsWith('.html');
367
+ })
368
+ .map(child => {
369
+ const isDir = child.isDirectory();
370
+ const baseName = isDir ? child.name : child.name.replace(/\.html$/, '');
371
+ return { child, isDir, baseName, ...resolveEntryNaming(isDir, baseName, sourceDir) };
372
+ })
373
+ .sort(compareEntries);
323
374
 
324
375
  // Build items, skipping directories with no documents
325
376
  const items = [];
326
- for (const child of filteredItems) {
327
- const isDir = child.isDirectory();
377
+ for (const { child, isDir, label } of filteredItems) {
328
378
  if (isDir) {
329
379
  const childDir = join(dir, child.name);
330
380
  if (!await directoryHasDocuments(childDir, OUTPUT_DOC_EXTENSIONS)) continue;
331
381
  }
332
- const name = isDir ? child.name : child.name.replace('.html', '');
333
382
  // For directories, link to /folder/index.html; for files, use the filename directly
334
383
  const href = isDir ? `${child.name}/index.html` : child.name;
335
- const displayName = toTitleCase(name);
336
384
  const icon = isDir ? '📁' : '📄';
337
- items.push(`<li>${icon} <a href="${href}">${displayName}</a></li>`);
385
+ items.push(`<li>${icon} <a href="${href}">${label}</a></li>`);
338
386
  }
339
387
 
340
388
  if (items.length === 0) {
@@ -342,12 +390,16 @@ export async function generateAutoIndices(output, directories, source, templates
342
390
  continue;
343
391
  }
344
392
 
345
- const folderDisplayName = dir === outputNorm ? 'Home' : toTitleCase(folderName);
393
+ // The page's own heading and <title> follow the same naming rules, so a
394
+ // folder labelled "BNW - Brave New World" in the menu is not "Bnw" here.
395
+ const folderDisplayName = dir === outputNorm
396
+ ? 'Home'
397
+ : getFolderLabel(sourceDir, getFolderConfig(sourceDir), folderName);
346
398
 
347
399
  // Generate breadcrumbs for auto-index pages
348
400
  const relDir = dir.replace(outputNorm, '').replace(/^\//, '');
349
401
  const breadcrumbDir = relDir ? relDir + '/' : '/';
350
- const breadcrumbHtml = generateBreadcrumbs(breadcrumbDir, 'index', null);
402
+ const breadcrumbHtml = generateBreadcrumbs(breadcrumbDir, 'index', null, sourceNorm);
351
403
 
352
404
  const indexHtml = `${breadcrumbHtml}<h1>${folderDisplayName}</h1>\n<ul class="auto-index">\n${items.join('\n')}\n</ul>`;
353
405
 
@@ -1,27 +1,8 @@
1
1
  // Helper for building the _ursa_metadata field embedded in generated JSON files
2
2
  import { existsSync } from "fs";
3
3
  import { readFile } from "fs/promises";
4
- import { dirname, join, resolve } from "path";
5
- import { URL } from "url";
6
-
7
- /**
8
- * Read the ursa version from ursa's own package.json
9
- * @returns {Promise<string>} The ursa version, or 'unknown' if it can't be read
10
- */
11
- async function getUrsaVersion() {
12
- try {
13
- // From src/helper/build/ursaMetadata.js, go up to the package root
14
- const currentDir = dirname(new URL(import.meta.url).pathname);
15
- const ursaPackagePath = resolve(currentDir, "..", "..", "..", "package.json");
16
- if (existsSync(ursaPackagePath)) {
17
- const ursaPackage = JSON.parse(await readFile(ursaPackagePath, "utf8"));
18
- if (ursaPackage.version) return ursaPackage.version;
19
- }
20
- } catch (e) {
21
- console.error(`Error reading ursa package.json: ${e.message}`);
22
- }
23
- return "unknown";
24
- }
4
+ import { join, resolve } from "path";
5
+ import { getUrsaVersion } from "../ursaVersion.js";
25
6
 
26
7
  /**
27
8
  * Read the documentation repo version from its package.json.
@@ -54,9 +35,5 @@ async function getDocVersion(_source) {
54
35
  * @returns {Promise<{ursaVersion: string, docVersion: string}>}
55
36
  */
56
37
  export async function getUrsaMetadata(_source) {
57
- const [ursaVersion, docVersion] = await Promise.all([
58
- getUrsaVersion(),
59
- getDocVersion(_source),
60
- ]);
61
- return { ursaVersion, docVersion };
38
+ return { ursaVersion: getUrsaVersion(), docVersion: await getDocVersion(_source) };
62
39
  }
@@ -1,10 +1,12 @@
1
1
  import { createHash } from 'crypto';
2
- import { readFile, writeFile, mkdir } from 'fs/promises';
2
+ import { readFile, writeFile, mkdir, rm } from 'fs/promises';
3
3
  import { existsSync } from 'fs';
4
4
  import { dirname, join } from 'path';
5
+ import { getUrsaVersion } from './ursaVersion.js';
5
6
 
6
7
  const URSA_DIR = '.ursa';
7
8
  const HASH_CACHE_FILE = 'content-hashes.json';
9
+ const CACHE_STAMP_FILE = 'cache-stamp.json';
8
10
 
9
11
  /**
10
12
  * Get the path to the .ursa directory for a given source directory
@@ -13,6 +15,57 @@ export function getUrsaDir(sourceDir) {
13
15
  return join(sourceDir, URSA_DIR);
14
16
  }
15
17
 
18
+ /**
19
+ * Stamp `.ursa/` with the running ursa version, discarding the whole directory
20
+ * first if it was written by a different one.
21
+ *
22
+ * Hash-skipping compares *source* content only. It says nothing about the code
23
+ * that turned that source into output, so a cache warmed by an older ursa keeps
24
+ * whole documents from being re-rendered even after the templates, renderers and
25
+ * asset bundles that produced them have changed — the symptom users hit as
26
+ * "I had to run --clean again". Run this before loading any cache.
27
+ *
28
+ * On a first build there is nothing to discard, and `reset` is false.
29
+ *
30
+ * @param {string} sourceDir - Source directory root
31
+ * @param {string} [version] - Version to stamp with; defaults to ursa's own
32
+ * @returns {Promise<{reset: boolean, previous: string|null, version: string}>}
33
+ */
34
+ export async function enforceCacheVersion(sourceDir, version = getUrsaVersion()) {
35
+ const ursaDir = getUrsaDir(sourceDir);
36
+ const stampPath = join(ursaDir, CACHE_STAMP_FILE);
37
+
38
+ let previous = null;
39
+ try {
40
+ if (existsSync(stampPath)) {
41
+ const stamp = JSON.parse(await readFile(stampPath, 'utf8'));
42
+ previous = typeof stamp?.ursaVersion === 'string' ? stamp.ursaVersion : null;
43
+ }
44
+ } catch (e) {
45
+ // An unreadable stamp tells us nothing about what wrote the cache, so treat
46
+ // it the same as a mismatch rather than trusting the caches beside it.
47
+ previous = null;
48
+ }
49
+
50
+ if (previous === version) return { reset: false, previous, version };
51
+
52
+ // A cache directory with no stamp predates stamping (or was hand-edited);
53
+ // either way its contents were not written by this ursa.
54
+ const hadCache = existsSync(ursaDir);
55
+ if (hadCache) await rm(ursaDir, { recursive: true, force: true });
56
+
57
+ try {
58
+ await mkdir(ursaDir, { recursive: true });
59
+ await writeFile(stampPath, JSON.stringify({ ursaVersion: version }, null, 2));
60
+ } catch (e) {
61
+ // A cache we cannot stamp is a cache we will discard again next run:
62
+ // correct, just slower. Not worth failing the build over.
63
+ console.warn('Could not write cache stamp:', e.message);
64
+ }
65
+
66
+ return { reset: hadCache, previous, version };
67
+ }
68
+
16
69
  /**
17
70
  * Generate a short hash of content
18
71
  */
@@ -0,0 +1,136 @@
1
+ /*
2
+ * Shared menu label/sort-key resolution.
3
+ *
4
+ * Both the site-wide automenu (helper/automenu.js) and the per-folder
5
+ * auto-index listings (helper/build/autoIndex.js) name the same folders and
6
+ * documents, so they must agree on what those things are called. Keeping the
7
+ * resolution rules here is what makes `menu-label: 'BNW - Brave New World'`
8
+ * show up in both places instead of only in the sidebar.
9
+ */
10
+ import { existsSync, readFileSync } from "fs";
11
+ import { basename, extname, join } from "path";
12
+ import { extractMetadata } from "./metadataExtractor.js";
13
+ import { stripHtml } from "./stripHtml.js";
14
+
15
+ // Index file extensions to check for folder metadata
16
+ export const INDEX_EXTENSIONS = ['.md', '.mdx', '.txt', '.yml', '.yaml'];
17
+
18
+ // Source extensions a rendered .html page can have come from
19
+ export const SOURCE_DOC_EXTENSIONS = ['.md', '.mdx', '.txt', '.yml', '.yaml', '.html'];
20
+
21
+ /**
22
+ * Convert filename to display name (e.g., "foo-bar" -> "Foo Bar").
23
+ * Unlike toTitleCase, this preserves interior capitalization, so a folder
24
+ * named "SoL" stays "SoL" rather than becoming "Sol".
25
+ */
26
+ export function toDisplayName(filename) {
27
+ return filename
28
+ .replace(/[-_]/g, ' ') // Replace dashes and underscores with spaces
29
+ .replace(/\b\w/g, c => c.toUpperCase()); // Capitalize first letter of each word
30
+ }
31
+
32
+ /**
33
+ * Read a single frontmatter key from a file, with HTML stripped.
34
+ * @param {string} filePath - Path to the source file
35
+ * @param {string} key - Frontmatter key to read
36
+ * @returns {string|null} The value, or null if absent/unreadable
37
+ */
38
+ function getFrontmatterString(filePath, key) {
39
+ try {
40
+ if (!existsSync(filePath)) return null;
41
+ const content = readFileSync(filePath, 'utf8');
42
+ const metadata = extractMetadata(content);
43
+ if (metadata && metadata[key]) {
44
+ return stripHtml(String(metadata[key]));
45
+ }
46
+ } catch (e) {
47
+ // Ignore read errors
48
+ }
49
+ return null;
50
+ }
51
+
52
+ /**
53
+ * Get the menu label from a file's frontmatter
54
+ * @param {string} filePath - Path to the markdown file
55
+ * @returns {string|null} The menu-label value (with HTML stripped), or null if not found
56
+ */
57
+ export function getMenuLabelFromFile(filePath) {
58
+ return getFrontmatterString(filePath, 'menu-label');
59
+ }
60
+
61
+ /**
62
+ * Get the menu-sort-as value from a file's frontmatter
63
+ * @param {string} filePath - Path to the markdown file
64
+ * @returns {string|null} The menu-sort-as value (with HTML stripped), or null if not found
65
+ */
66
+ export function getMenuSortAsFromFile(filePath) {
67
+ return getFrontmatterString(filePath, 'menu-sort-as');
68
+ }
69
+
70
+ /**
71
+ * Get the menu label for a folder from its index.md frontmatter
72
+ * Falls back to config.json label (deprecated), then display name
73
+ * @param {string} dirPath - Path to the folder
74
+ * @param {object|null} folderConfig - The folder's config.json if any
75
+ * @param {string} baseName - The folder's base name
76
+ * @returns {string} The label to display
77
+ */
78
+ export function getFolderLabel(dirPath, folderConfig, baseName) {
79
+ // First, check index.md for menu-label (preferred method)
80
+ for (const ext of INDEX_EXTENSIONS) {
81
+ const indexPath = join(dirPath, `index${ext}`);
82
+ const label = getMenuLabelFromFile(indexPath);
83
+ if (label) return label;
84
+ }
85
+
86
+ // Fall back to config.json label (deprecated)
87
+ if (folderConfig?.label) {
88
+ return folderConfig.label;
89
+ }
90
+
91
+ // Default to display name from folder name
92
+ return toDisplayName(baseName);
93
+ }
94
+
95
+ /**
96
+ * Get the sort key for a folder from its index.md frontmatter
97
+ * @param {string} dirPath - Path to the folder
98
+ * @returns {string|null} The menu-sort-as value, or null if not found
99
+ */
100
+ export function getFolderSortKey(dirPath) {
101
+ for (const ext of INDEX_EXTENSIONS) {
102
+ const indexPath = join(dirPath, `index${ext}`);
103
+ const sortKey = getMenuSortAsFromFile(indexPath);
104
+ if (sortKey) return sortKey;
105
+ }
106
+ return null;
107
+ }
108
+
109
+ /**
110
+ * Locate the source document that produced (or would produce) a page.
111
+ * Auto-index listings built from the OUTPUT folder only see "foo.html"; this
112
+ * finds the "foo.md" it came from so its frontmatter can be read.
113
+ * @param {string} dir - Source directory to look in
114
+ * @param {string} baseName - File name without extension
115
+ * @returns {string|null} Path to the source document, or null if none exists
116
+ */
117
+ export function findSourceDocument(dir, baseName) {
118
+ if (!dir) return null;
119
+ for (const ext of SOURCE_DOC_EXTENSIONS) {
120
+ const candidate = join(dir, `${baseName}${ext}`);
121
+ if (existsSync(candidate)) return candidate;
122
+ }
123
+ return null;
124
+ }
125
+
126
+ /**
127
+ * Get the menu label for a single document.
128
+ * @param {string|null} filePath - Path to the source document (may be null)
129
+ * @param {string} [baseName] - Fallback name; defaults to the file's base name
130
+ * @returns {string} The label to display
131
+ */
132
+ export function getFileLabel(filePath, baseName) {
133
+ const fallback = baseName ?? (filePath ? basename(filePath, extname(filePath)) : '');
134
+ if (!filePath) return toDisplayName(fallback);
135
+ return getMenuLabelFromFile(filePath) || toDisplayName(fallback);
136
+ }
@@ -0,0 +1,26 @@
1
+ import { existsSync, readFileSync } from "fs";
2
+ import { dirname, resolve } from "path";
3
+ import { fileURLToPath } from "url";
4
+
5
+ let cached = null;
6
+
7
+ /**
8
+ * Read ursa's own version from the package.json that ships with it.
9
+ * Memoised — it cannot change while the process runs.
10
+ * @returns {string} The version, or 'unknown' if it can't be read
11
+ */
12
+ export function getUrsaVersion() {
13
+ if (cached) return cached;
14
+ try {
15
+ // From src/helper/ursaVersion.js, go up to the package root
16
+ const currentDir = dirname(fileURLToPath(import.meta.url));
17
+ const ursaPackagePath = resolve(currentDir, "..", "..", "package.json");
18
+ if (existsSync(ursaPackagePath)) {
19
+ const ursaPackage = JSON.parse(readFileSync(ursaPackagePath, "utf8"));
20
+ if (ursaPackage.version) return (cached = ursaPackage.version);
21
+ }
22
+ } catch (e) {
23
+ console.error(`Error reading ursa package.json: ${e.message}`);
24
+ }
25
+ return (cached = "unknown");
26
+ }
@@ -21,6 +21,7 @@ import {
21
21
  outputsExist,
22
22
  updateHash,
23
23
  getUrsaDir,
24
+ enforceCacheVersion,
24
25
  } from "../helper/contentHash.js";
25
26
  import {
26
27
  buildValidPaths,
@@ -168,7 +169,20 @@ export async function generate({
168
169
  progress.logTimed(`Clean build: clearing output directory ${output}`);
169
170
  await emptyDir(output);
170
171
  progress.logTimed(`Clean complete [${progress.stopTimer('Clean')}]`);
171
- } else {
172
+ }
173
+
174
+ // Stamp the cache with ursa's version, discarding it if a different ursa
175
+ // wrote it. Hash-skipping only compares source content, so without this an
176
+ // upgrade leaves every unchanged document frozen at whatever the previous
177
+ // version's templates and renderers produced.
178
+ const cacheStamp = await enforceCacheVersion(source);
179
+ if (cacheStamp.reset) {
180
+ progress.logTimed(
181
+ `Cache discarded: written by ursa ${cacheStamp.previous ?? '(unstamped)'}, now running ${cacheStamp.version}`
182
+ );
183
+ }
184
+
185
+ if (!_clean) {
172
186
  // Warm start: reload persisted dependency registrations so hash-skipped
173
187
  // documents keep their edges (current-run registrations take precedence)
174
188
  const loaded = await loadDependencyTracker(source);
@@ -734,7 +748,7 @@ export async function generate({
734
748
  }
735
749
 
736
750
  // Inject breadcrumbs before the H1
737
- const breadcrumbs = generateBreadcrumbs(dir, base, fileMeta);
751
+ const breadcrumbs = generateBreadcrumbs(dir, base, fileMeta, source);
738
752
  if (breadcrumbs) {
739
753
  body = breadcrumbs + body;
740
754
  }
@@ -1552,7 +1566,7 @@ export async function regenerateSingleFile(changedFile, {
1552
1566
  }
1553
1567
 
1554
1568
  // Inject breadcrumbs before the H1
1555
- const breadcrumbs = generateBreadcrumbs(dir, base, fileMeta);
1569
+ const breadcrumbs = generateBreadcrumbs(dir, base, fileMeta, source);
1556
1570
  if (breadcrumbs) {
1557
1571
  body = breadcrumbs + body;
1558
1572
  }