@kenjura/ursa 0.90.1 → 0.95.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.
@@ -1,4 +1,6 @@
1
- import { toTitleCase } from "./build/titleCase.js";
1
+ import { join } from "path";
2
+ import { getFolderConfig } from "./folderConfig.js";
3
+ import { toDisplayName, getFolderLabel } from "./menuLabels.js";
2
4
 
3
5
  /**
4
6
  * Generate breadcrumb navigation HTML from a document's path.
@@ -6,9 +8,12 @@ import { toTitleCase } from "./build/titleCase.js";
6
8
  * @param {string} dir - Directory relative to source root, e.g. "settings/eberron/" or "/"
7
9
  * @param {string} base - Filename without extension, e.g. "index" or "places"
8
10
  * @param {object} [fileMeta] - Parsed frontmatter (used for current-page label override)
11
+ * @param {string|null} [sourceRoot] - Absolute path of the docroot. With it, folder
12
+ * segments are named the way the menu names them (`menu-label` frontmatter, then
13
+ * config.json `label`); without it they fall back to the prettified folder name.
9
14
  * @returns {string} Breadcrumb HTML string, or empty string if not applicable
10
15
  */
11
- export function generateBreadcrumbs(dir, base, fileMeta) {
16
+ export function generateBreadcrumbs(dir, base, fileMeta, sourceRoot = null) {
12
17
  const segments = dir.split('/').filter(Boolean);
13
18
  const isIndexFile = (base === 'index' || base === 'home');
14
19
 
@@ -25,10 +30,20 @@ export function generateBreadcrumbs(dir, base, fileMeta) {
25
30
  const seg = allSegments[i];
26
31
  const isLast = i === allSegments.length - 1;
27
32
 
28
- // Current page can use frontmatter title; others use title-cased folder name
29
- const label = isLast && fileMeta?.title
30
- ? fileMeta.title
31
- : toTitleCase(seg);
33
+ // Every segment but the last is a folder, and so is the last one when this
34
+ // is a folder's index page. Those get the menu's label. The last segment of
35
+ // a regular document is the document itself, which keeps its own
36
+ // frontmatter override.
37
+ const isFolderSegment = !isLast || isIndexFile;
38
+ let label;
39
+ if (isLast && !isFolderSegment) {
40
+ label = fileMeta?.['menu-label'] || fileMeta?.title || toDisplayName(seg);
41
+ } else if (sourceRoot) {
42
+ const folderPath = join(sourceRoot, ...allSegments.slice(0, i + 1));
43
+ label = getFolderLabel(folderPath, getFolderConfig(folderPath), seg);
44
+ } else {
45
+ label = toDisplayName(seg);
46
+ }
32
47
 
33
48
  if (isLast) {
34
49
  parts.push(`<span class="breadcrumb-current" aria-current="page">${label}</span>`);
@@ -2,7 +2,8 @@ import { join } from "path";
2
2
  import { mkdtemp, mkdir, writeFile, rm, readFile } from "fs/promises";
3
3
  import { existsSync } from "fs";
4
4
  import { tmpdir } from "os";
5
- import { generateAutoIndices } from "../autoIndex.js";
5
+ import { generateAutoIndices, generateAutoIndexHtmlFromSource } from "../autoIndex.js";
6
+ import { clearConfigCache } from "../../folderConfig.js";
6
7
 
7
8
  let tempDir;
8
9
  let source;
@@ -93,3 +94,136 @@ describe("generateAutoIndices with empty source folders", () => {
93
94
  expect(rootIndex).toContain('<a href="docs/index.html">');
94
95
  });
95
96
  });
97
+
98
+ describe("auto-index naming matches the automenu", () => {
99
+ it("uses menu-label from a folder's index frontmatter, and config.json when there is no index", async () => {
100
+ // bnw has a real index.md carrying the label; hfr has no index at all, so
101
+ // it falls back to config.json — the same two-step the automenu uses.
102
+ await mkdir(join(source, "bnw"));
103
+ await writeFile(
104
+ join(source, "bnw", "index.md"),
105
+ "---\nmenu-label: 'BNW - Brave New World'\n---\n"
106
+ );
107
+ await writeFile(join(source, "bnw", "quests.md"), "# Quests\n");
108
+ await mkdir(join(source, "hfr"));
109
+ await writeFile(
110
+ join(source, "hfr", "config.json"),
111
+ JSON.stringify({ label: "HFR - Hyacinth: Fury Road" })
112
+ );
113
+ await writeFile(join(source, "hfr", "hfr.md"), "# Hyacinth\n");
114
+
115
+ const html = await generateAutoIndexHtmlFromSource(source, 1);
116
+
117
+ expect(html).toContain('<a href="bnw/index.html">BNW - Brave New World</a>');
118
+ expect(html).toContain('<a href="hfr/index.html">HFR - Hyacinth: Fury Road</a>');
119
+ });
120
+
121
+ it("uses menu-label on individual documents and menu-sort-as for ordering", async () => {
122
+ await writeFile(
123
+ join(source, "zebra.md"),
124
+ "---\nmenu-label: 'ZED - Zebra'\nmenu-sort-as: 'aardvark'\n---\n# Zebra\n"
125
+ );
126
+ await writeFile(join(source, "middle.md"), "# Middle\n");
127
+
128
+ const html = await generateAutoIndexHtmlFromSource(source, 1);
129
+
130
+ expect(html).toContain('<a href="zebra.html">ZED - Zebra</a>');
131
+ // Sorted by menu-sort-as ("aardvark"), not by filename ("zebra")
132
+ expect(html.indexOf("ZED - Zebra")).toBeLessThan(html.indexOf("Middle"));
133
+ });
134
+
135
+ it("preserves interior capitalization instead of title-casing it away", async () => {
136
+ await mkdir(join(source, "SoL"));
137
+ await writeFile(join(source, "SoL", "notes.md"), "# Notes\n");
138
+
139
+ const html = await generateAutoIndexHtmlFromSource(source, 1);
140
+
141
+ expect(html).toContain('<a href="SoL/index.html">SoL</a>');
142
+ });
143
+
144
+ it("labels generated index pages with the folder's menu-label", async () => {
145
+ // No index.md content, so this folder gets an auto-generated index.html;
146
+ // its <h1> should use the label rather than the raw folder name.
147
+ await mkdir(join(source, "bnw"));
148
+ await writeFile(
149
+ join(source, "bnw", "config.json"),
150
+ JSON.stringify({ label: "BNW - Brave New World" })
151
+ );
152
+ await writeFile(join(source, "bnw", "quests.md"), "# Quests\n");
153
+ await mkdir(join(output, "bnw"));
154
+ await writeFile(join(output, "bnw", "quests.html"), "<html><body>Quests</body></html>");
155
+
156
+ await runAutoIndices([join(source, "bnw")], [join(source, "bnw", "quests.md")], makeProgress());
157
+
158
+ const bnwIndex = await readFile(join(output, "bnw", "index.html"), "utf8");
159
+ expect(bnwIndex).toContain("<h1>BNW - Brave New World</h1>");
160
+ const rootIndex = await readFile(join(output, "index.html"), "utf8");
161
+ expect(rootIndex).toContain('<a href="bnw/index.html">BNW - Brave New World</a>');
162
+ });
163
+ });
164
+
165
+ describe("folders ignored via config.json { hidden: true }", () => {
166
+ beforeEach(() => {
167
+ // getFolderConfig memoizes per absolute path; temp dirs are unique per
168
+ // test, but clearing keeps the cache from growing across the suite.
169
+ clearConfigCache();
170
+ });
171
+
172
+ it("omits a hidden folder from an auto-index built from source", async () => {
173
+ await mkdir(join(source, "_art"), { recursive: true });
174
+ await writeFile(join(source, "_art", "config.json"), JSON.stringify({ hidden: true }));
175
+ await writeFile(join(source, "_art", "prompts.md"), "# Prompts\n");
176
+ await mkdir(join(source, "people"), { recursive: true });
177
+ await writeFile(join(source, "people", "alice.md"), "# Alice\n");
178
+
179
+ const html = await generateAutoIndexHtmlFromSource(source, 2);
180
+
181
+ expect(html).toContain("people");
182
+ expect(html).not.toContain("_art");
183
+ expect(html).not.toContain("prompts");
184
+ });
185
+
186
+ it("omits a hidden folder even when stale output for it still exists", async () => {
187
+ // A folder generated before it was hidden leaves files behind in output.
188
+ // The listing is built from output, so without a source-side check the
189
+ // hidden folder would reappear in the index.
190
+ await mkdir(join(source, "_art"), { recursive: true });
191
+ await writeFile(join(source, "_art", "config.json"), JSON.stringify({ hidden: true }));
192
+ await mkdir(join(source, "people"), { recursive: true });
193
+ await writeFile(join(source, "people", "alice.md"), "# Alice\n");
194
+
195
+ await mkdir(join(output, "_art"), { recursive: true });
196
+ await writeFile(join(output, "_art", "prompts.html"), "<html><body>stale</body></html>");
197
+ await mkdir(join(output, "people"), { recursive: true });
198
+ await writeFile(join(output, "people", "alice.html"), "<html><body>Alice</body></html>");
199
+
200
+ const progress = makeProgress();
201
+ await runAutoIndices(
202
+ [source, join(source, "people")],
203
+ [join(source, "people", "alice.md")],
204
+ progress
205
+ );
206
+
207
+ const index = await readFile(join(output, "index.html"), "utf8");
208
+ expect(index).toContain("people");
209
+ expect(index).not.toContain("_art");
210
+ });
211
+
212
+ it("does not count documents inside a hidden subfolder when deciding a folder has content", async () => {
213
+ // `notes` holds nothing but a hidden subfolder, so it produces no pages
214
+ // and must not be linked as though it did.
215
+ await mkdir(join(source, "notes", "_art"), { recursive: true });
216
+ await writeFile(
217
+ join(source, "notes", "_art", "config.json"),
218
+ JSON.stringify({ hidden: true })
219
+ );
220
+ await writeFile(join(source, "notes", "_art", "prompts.md"), "# Prompts\n");
221
+ await mkdir(join(source, "people"), { recursive: true });
222
+ await writeFile(join(source, "people", "alice.md"), "# Alice\n");
223
+
224
+ const html = await generateAutoIndexHtmlFromSource(source, 2);
225
+
226
+ expect(html).toContain("people");
227
+ expect(html).not.toContain("notes");
228
+ });
229
+ });
@@ -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, isFolderSelfHidden } 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
 
@@ -18,11 +26,18 @@ const OUTPUT_DOC_EXTENSIONS = ['.html'];
18
26
 
19
27
  /**
20
28
  * Recursively check if a directory contains any document files.
29
+ *
30
+ * Documents inside a config-hidden subfolder do not count: they produce no
31
+ * output, so a folder whose only contents are hidden must not be linked as if
32
+ * it had pages. When `dir` is an output directory, pass the matching source
33
+ * directory as `sourceDir` — the config.json lives in the source tree.
34
+ *
21
35
  * @param {string} dir - Directory path to check
22
36
  * @param {string[]} extensions - File extensions that count as documents
37
+ * @param {string|null} [sourceDir=dir] - Matching source directory, for hidden lookups
23
38
  * @returns {Promise<boolean>} True if the directory (or any subdirectory) contains at least one document
24
39
  */
25
- async function directoryHasDocuments(dir, extensions) {
40
+ async function directoryHasDocuments(dir, extensions, sourceDir = dir) {
26
41
  try {
27
42
  const children = await readdir(dir, { withFileTypes: true });
28
43
  for (const child of children) {
@@ -30,7 +45,9 @@ async function directoryHasDocuments(dir, extensions) {
30
45
  const fullPath = join(dir, child.name);
31
46
  if (child.isDirectory()) {
32
47
  if (child.name === 'img') continue;
33
- if (await directoryHasDocuments(fullPath, extensions)) return true;
48
+ if (sourceDir && isFolderSelfHidden(join(sourceDir, child.name))) continue;
49
+ const childSource = sourceDir ? join(sourceDir, child.name) : null;
50
+ if (await directoryHasDocuments(fullPath, extensions, childSource)) return true;
34
51
  } else {
35
52
  const ext = extname(child.name).toLowerCase();
36
53
  if (extensions.includes(ext)) return true;
@@ -40,6 +57,44 @@ async function directoryHasDocuments(dir, extensions) {
40
57
  return false;
41
58
  }
42
59
 
60
+ /**
61
+ * Resolve the label and sort key for one auto-index entry, using the same
62
+ * rules as the site-wide automenu: `menu-label` frontmatter wins, then
63
+ * config.json `label` for folders, then the prettified file/folder name.
64
+ *
65
+ * @param {boolean} isDir - Whether the entry is a directory
66
+ * @param {string} baseName - Name without extension
67
+ * @param {string|null} sourceDir - Source directory holding this entry, if known
68
+ * @returns {{label: string, sortKey: string}}
69
+ */
70
+ function resolveEntryNaming(isDir, baseName, sourceDir) {
71
+ if (!sourceDir) {
72
+ return { label: toDisplayName(baseName), sortKey: baseName };
73
+ }
74
+ if (isDir) {
75
+ const childDir = join(sourceDir, baseName);
76
+ return {
77
+ label: getFolderLabel(childDir, getFolderConfig(childDir), baseName),
78
+ sortKey: getFolderSortKey(childDir) || baseName,
79
+ };
80
+ }
81
+ const sourceFile = findSourceDocument(sourceDir, baseName);
82
+ return {
83
+ label: getFileLabel(sourceFile, baseName),
84
+ sortKey: (sourceFile && getMenuSortAsFromFile(sourceFile)) || baseName,
85
+ };
86
+ }
87
+
88
+ /**
89
+ * Sort auto-index entries the way the automenu does: folders first, then by
90
+ * sort key (case-insensitive).
91
+ */
92
+ function compareEntries(a, b) {
93
+ if (a.isDir && !b.isDir) return -1;
94
+ if (!a.isDir && b.isDir) return 1;
95
+ return a.sortKey.toLowerCase().localeCompare(b.sortKey.toLowerCase());
96
+ }
97
+
43
98
  /**
44
99
  * Generate auto-index HTML content for a directory from the OUTPUT folder
45
100
  * (used by fallback auto-index generation after all files are generated)
@@ -47,9 +102,12 @@ async function directoryHasDocuments(dir, extensions) {
47
102
  * @param {number} depth - How deep to recurse (1 = current level only, 2 = current + children, etc.)
48
103
  * @param {number} [currentDepth=0] - Current recursion depth (internal use)
49
104
  * @param {string} [pathPrefix=''] - Path prefix for generating correct hrefs (internal use)
105
+ * @param {string|null} [sourceDir=null] - Matching source directory, so `menu-label`
106
+ * frontmatter and config.json labels can be honored. Without it, entries fall
107
+ * back to their prettified file names.
50
108
  * @returns {Promise<string>} HTML content for the auto-index
51
109
  */
52
- export async function generateAutoIndexHtml(dir, depth = 1, currentDepth = 0, pathPrefix = '') {
110
+ export async function generateAutoIndexHtml(dir, depth = 1, currentDepth = 0, pathPrefix = '', sourceDir = null) {
53
111
  try {
54
112
  const children = await readdir(dir, { withFileTypes: true });
55
113
 
@@ -62,15 +120,18 @@ export async function generateAutoIndexHtml(dir, depth = 1, currentDepth = 0, pa
62
120
  if (child.name === 'index.html') return false;
63
121
  // Skip img folders (contain images, not content)
64
122
  if (child.isDirectory() && child.name === 'img') return false;
123
+ // Skip folders config.json marks hidden — they are ignored entirely,
124
+ // so a stale output directory must not resurrect them in a listing
125
+ if (child.isDirectory() && sourceDir && isFolderSelfHidden(join(sourceDir, child.name))) return false;
65
126
  // Include directories and html files
66
127
  return child.isDirectory() || child.name.endsWith('.html');
67
128
  })
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
- });
129
+ .map(child => {
130
+ const isDir = child.isDirectory();
131
+ const baseName = isDir ? child.name : child.name.replace(/\.html$/, '');
132
+ return { child, isDir, baseName, ...resolveEntryNaming(isDir, baseName, sourceDir) };
133
+ })
134
+ .sort(compareEntries);
74
135
 
75
136
  if (filteredChildren.length === 0) {
76
137
  return '';
@@ -78,26 +139,27 @@ export async function generateAutoIndexHtml(dir, depth = 1, currentDepth = 0, pa
78
139
 
79
140
  const items = [];
80
141
 
81
- for (const child of filteredChildren) {
82
- const isDir = child.isDirectory();
142
+ for (const { child, isDir, label } of filteredChildren) {
83
143
  // Skip directories that contain no documents
84
144
  if (isDir) {
85
145
  const childDir = join(dir, child.name);
86
- if (!await directoryHasDocuments(childDir, OUTPUT_DOC_EXTENSIONS)) continue;
146
+ const childSource = sourceDir ? join(sourceDir, child.name) : null;
147
+ if (!await directoryHasDocuments(childDir, OUTPUT_DOC_EXTENSIONS, childSource)) continue;
87
148
  }
88
- const name = isDir ? child.name : child.name.replace('.html', '');
89
149
  // Use pathPrefix to ensure hrefs are correct relative to the document root
90
150
  const childPath = pathPrefix ? `${pathPrefix}/${child.name}` : child.name;
91
151
  const href = isDir ? `${childPath}/index.html` : (pathPrefix ? `${pathPrefix}/${child.name}` : child.name);
92
- const displayName = toTitleCase(name);
93
152
  const icon = isDir ? '📁' : '📄';
94
153
 
95
- let itemHtml = `<li>${icon} <a href="${href}">${displayName}</a>`;
154
+ let itemHtml = `<li>${icon} <a href="${href}">${label}</a>`;
96
155
 
97
156
  // If this is a directory and we need to go deeper, recurse
98
157
  if (isDir && currentDepth + 1 < depth) {
99
158
  const childDir = join(dir, child.name);
100
- const childHtml = await generateAutoIndexHtml(childDir, depth, currentDepth + 1, childPath);
159
+ const childHtml = await generateAutoIndexHtml(
160
+ childDir, depth, currentDepth + 1, childPath,
161
+ sourceDir ? join(sourceDir, child.name) : null
162
+ );
101
163
  if (childHtml) {
102
164
  itemHtml += `\n${childHtml}`;
103
165
  }
@@ -137,15 +199,17 @@ export async function generateAutoIndexHtmlFromSource(sourceDir, depth = 1, curr
137
199
  if (child.name.match(/^index\.(md|mdx|txt|yml|html)$/i)) return false;
138
200
  // Skip img folders (contain images, not content)
139
201
  if (child.isDirectory() && child.name === 'img') return false;
202
+ // Skip folders config.json marks hidden — they produce no output
203
+ if (child.isDirectory() && isFolderSelfHidden(join(sourceDir, child.name))) return false;
140
204
  // Include directories and article files (md, mdx, txt, yml, html)
141
205
  return child.isDirectory() || child.name.match(/\.(md|mdx|txt|yml|html)$/i);
142
206
  })
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
- });
207
+ .map(child => {
208
+ const isDir = child.isDirectory();
209
+ const baseName = isDir ? child.name : basename(child.name, extname(child.name));
210
+ return { child, isDir, baseName, ...resolveEntryNaming(isDir, baseName, sourceDir) };
211
+ })
212
+ .sort(compareEntries);
149
213
 
150
214
  if (filteredChildren.length === 0) {
151
215
  return '';
@@ -153,24 +217,19 @@ export async function generateAutoIndexHtmlFromSource(sourceDir, depth = 1, curr
153
217
 
154
218
  const items = [];
155
219
 
156
- for (const child of filteredChildren) {
157
- const isDir = child.isDirectory();
220
+ for (const { child, isDir, baseName, label } of filteredChildren) {
158
221
  // Skip directories that contain no documents
159
222
  if (isDir) {
160
223
  const childDir = join(sourceDir, child.name);
161
224
  if (!await directoryHasDocuments(childDir, SOURCE_DOC_EXTENSIONS)) continue;
162
225
  }
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
226
  // Generate href - directories link to folder/index.html, files convert to .html
167
227
  // Use pathPrefix to ensure hrefs are correct relative to the document root
168
228
  const childPath = pathPrefix ? `${pathPrefix}/${child.name}` : child.name;
169
- const href = isDir ? `${childPath}/index.html` : `${pathPrefix ? pathPrefix + '/' : ''}${nameWithoutExt}.html`;
170
- const displayName = toTitleCase(nameWithoutExt);
229
+ const href = isDir ? `${childPath}/index.html` : `${pathPrefix ? pathPrefix + '/' : ''}${baseName}.html`;
171
230
  const icon = isDir ? '📁' : '📄';
172
231
 
173
- let itemHtml = `<li>${icon} <a href="${href}">${displayName}</a>`;
232
+ let itemHtml = `<li>${icon} <a href="${href}">${label}</a>`;
174
233
 
175
234
  // If this is a directory and we need to go deeper, recurse
176
235
  if (isDir && currentDepth + 1 < depth) {
@@ -313,28 +372,34 @@ export async function generateAutoIndices(output, directories, source, templates
313
372
  const children = await readdir(dir, { withFileTypes: true });
314
373
 
315
374
  // 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
- });
375
+ const filteredItems = children
376
+ .filter(child => {
377
+ // Skip hidden files and index alternates we just checked
378
+ if (child.name.startsWith('.')) return false;
379
+ if (child.name === 'index.html') return false;
380
+ // Skip folders config.json marks hidden — they produce no output
381
+ if (child.isDirectory() && isFolderSelfHidden(join(sourceDir, child.name))) return false;
382
+ // Include directories and html files
383
+ return child.isDirectory() || child.name.endsWith('.html');
384
+ })
385
+ .map(child => {
386
+ const isDir = child.isDirectory();
387
+ const baseName = isDir ? child.name : child.name.replace(/\.html$/, '');
388
+ return { child, isDir, baseName, ...resolveEntryNaming(isDir, baseName, sourceDir) };
389
+ })
390
+ .sort(compareEntries);
323
391
 
324
392
  // Build items, skipping directories with no documents
325
393
  const items = [];
326
- for (const child of filteredItems) {
327
- const isDir = child.isDirectory();
394
+ for (const { child, isDir, label } of filteredItems) {
328
395
  if (isDir) {
329
396
  const childDir = join(dir, child.name);
330
- if (!await directoryHasDocuments(childDir, OUTPUT_DOC_EXTENSIONS)) continue;
397
+ if (!await directoryHasDocuments(childDir, OUTPUT_DOC_EXTENSIONS, join(sourceDir, child.name))) continue;
331
398
  }
332
- const name = isDir ? child.name : child.name.replace('.html', '');
333
399
  // For directories, link to /folder/index.html; for files, use the filename directly
334
400
  const href = isDir ? `${child.name}/index.html` : child.name;
335
- const displayName = toTitleCase(name);
336
401
  const icon = isDir ? '📁' : '📄';
337
- items.push(`<li>${icon} <a href="${href}">${displayName}</a></li>`);
402
+ items.push(`<li>${icon} <a href="${href}">${label}</a></li>`);
338
403
  }
339
404
 
340
405
  if (items.length === 0) {
@@ -342,12 +407,16 @@ export async function generateAutoIndices(output, directories, source, templates
342
407
  continue;
343
408
  }
344
409
 
345
- const folderDisplayName = dir === outputNorm ? 'Home' : toTitleCase(folderName);
410
+ // The page's own heading and <title> follow the same naming rules, so a
411
+ // folder labelled "BNW - Brave New World" in the menu is not "Bnw" here.
412
+ const folderDisplayName = dir === outputNorm
413
+ ? 'Home'
414
+ : getFolderLabel(sourceDir, getFolderConfig(sourceDir), folderName);
346
415
 
347
416
  // Generate breadcrumbs for auto-index pages
348
417
  const relDir = dir.replace(outputNorm, '').replace(/^\//, '');
349
418
  const breadcrumbDir = relDir ? relDir + '/' : '/';
350
- const breadcrumbHtml = generateBreadcrumbs(breadcrumbDir, 'index', null);
419
+ const breadcrumbHtml = generateBreadcrumbs(breadcrumbDir, 'index', null, sourceNorm);
351
420
 
352
421
  const indexHtml = `${breadcrumbHtml}<h1>${folderDisplayName}</h1>\n<ul class="auto-index">\n${items.join('\n')}\n</ul>`;
353
422
 
@@ -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
  */
@@ -11,9 +11,16 @@ const configCache = new Map();
11
11
  * {
12
12
  * label?: string, // Custom label for menu display
13
13
  * icon?: string, // URL to icon image for menu
14
- * hidden?: boolean, // If true, hide from menu and don't generate files
14
+ * hidden?: boolean, // If true, ignore the folder entirely (see below)
15
15
  * openMenuItems?: string[] // (root only) Array of folder names to expand by default
16
16
  * }
17
+ *
18
+ * `hidden: true` means *ignored*, not merely unlisted. The folder and its
19
+ * whole subtree take no part in the build: no HTML is rendered from its
20
+ * documents, its images and other static assets are not copied, it does not
21
+ * appear in the sidebar menu, in any auto-index, in breadcrumbs, or in the
22
+ * search index, and `ursa serve` will not render its pages on demand. The
23
+ * files stay in the docroot; the site behaves as if they were not there.
17
24
  */
18
25
 
19
26
  /**
@@ -60,10 +67,33 @@ export function getRootConfig(sourceRoot) {
60
67
  }
61
68
 
62
69
  /**
63
- * Check if a folder or any of its ancestors is hidden via config.json
64
- * @param {string} folderPath - Absolute path to check
70
+ * True when this exact folder's own config.json says `hidden: true`, ignoring
71
+ * its ancestors.
72
+ *
73
+ * Use this where the ancestors have already been ruled out — walking a tree
74
+ * top-down, say, where reaching a node means every folder above it was
75
+ * visible. It needs no docroot, which is what makes it usable in the
76
+ * auto-index builders, where only the folder being listed is known.
77
+ *
78
+ * @param {string} folderPath - Absolute path to a folder
79
+ * @returns {boolean} True if that folder is marked hidden
80
+ */
81
+ export function isFolderSelfHidden(folderPath) {
82
+ return getFolderConfig(folderPath.replace(/\/$/, ''))?.hidden === true;
83
+ }
84
+
85
+ /**
86
+ * Check if a path lies in a folder — its own, or any ancestor up to the
87
+ * docroot — that config.json marks `hidden: true`.
88
+ *
89
+ * Accepts file paths as well as directories: the walk starts at `folderPath`
90
+ * itself, and a file simply has no config.json of its own, so the first step
91
+ * misses and the ancestors decide. That is what lets the build filter a mixed
92
+ * list of files and directories through one predicate.
93
+ *
94
+ * @param {string} folderPath - Absolute path to check (file or directory)
65
95
  * @param {string} sourceRoot - The source root directory (stop checking at this level)
66
- * @returns {boolean} True if this folder should be hidden
96
+ * @returns {boolean} True if this path should be ignored
67
97
  */
68
98
  export function isFolderHidden(folderPath, sourceRoot) {
69
99
  let currentPath = folderPath.replace(/\/$/, '');