@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.
@@ -6,10 +6,15 @@
6
6
  * dropdown panels. One widget can be open per side at a time.
7
7
  *
8
8
  * Widget state (open/closed) is persisted in localStorage so it survives page reloads.
9
+ *
10
+ * An open panel carries data-active-widget naming whichever widget is showing,
11
+ * and loses it when it closes. The panels are shared containers, so this is what
12
+ * lets CSS — Ursa's own and a site's — tell one widget's panel from another's.
13
+ * See "Styling one widget at a time" in the README.
9
14
  *
10
15
  * Built-in widgets:
11
16
  * Left: Recent Activity (open by default)
12
- * Right: TOC, Search, Profile
17
+ * Right: TOC (open by default, persistent), Search, Profile
13
18
  */
14
19
  class WidgetManager {
15
20
  constructor() {
@@ -19,8 +24,31 @@ class WidgetManager {
19
24
  this.activeRight = null;
20
25
  this.activeLeft = null;
21
26
 
22
- // Widgets that default to open on first visit
23
- this.defaultOpen = new Set(['recent-activity']);
27
+ // Widgets that default to open on first visit. The TOC is here because it
28
+ // is reference furniture rather than a tool you go and fetch: it belongs
29
+ // beside the article the way page numbers belong on a page. It costs
30
+ // nothing to leave up — the stylesheet gives it the right margin, and
31
+ // takes it away again on a viewport with no margin to spare.
32
+ this.defaultOpen = new Set(['recent-activity', 'toc']);
33
+
34
+ // Widgets that are furniture rather than a drawer. Two things follow.
35
+ //
36
+ // A click elsewhere on the page does not dismiss them. Light dismissal is
37
+ // right for something you pull open, glance at and are done with, but the
38
+ // first half of following a link is a click on the page — so for a widget
39
+ // meant to stay up it fired constantly, and, because dismissal is recorded,
40
+ // it wrote down "the reader closed this" every time. One click on an
41
+ // article and the TOC was off for good, on that page and every page after.
42
+ // Escape is out for the same reason: it would be remembered.
43
+ //
44
+ // And they own their side of the nav. Another widget opening there is
45
+ // borrowing it, not replacing them — the loan is not recorded as a closure,
46
+ // and they come back when it is handed back.
47
+ //
48
+ // Which leaves the button in the nav and the panel's own close button as
49
+ // the only two things that decide whether a persistent widget is showing.
50
+ // Those are unambiguous, and those are remembered.
51
+ this.persistent = new Set(['toc']);
24
52
 
25
53
  if (this.buttons.length === 0) return;
26
54
 
@@ -86,12 +114,14 @@ class WidgetManager {
86
114
  document.addEventListener('click', (e) => {
87
115
  // Close right-side widget if click is outside
88
116
  if (this.activeRight && this.dropdownRight &&
117
+ !this.persistent.has(this.activeRight) &&
89
118
  !this.dropdownRight.contains(e.target) &&
90
119
  !e.target.closest('.widget-button')) {
91
120
  this.close('right');
92
121
  }
93
122
  // Close left-side widget if click is outside
94
123
  if (this.activeLeft && this.dropdownLeft &&
124
+ !this.persistent.has(this.activeLeft) &&
95
125
  !this.dropdownLeft.contains(e.target) &&
96
126
  !e.target.closest('.widget-button')) {
97
127
  this.close('left');
@@ -101,8 +131,8 @@ class WidgetManager {
101
131
  // Close on Escape
102
132
  document.addEventListener('keydown', (e) => {
103
133
  if (e.key === 'Escape') {
104
- if (this.activeRight) this.close('right');
105
- if (this.activeLeft) this.close('left');
134
+ if (this.activeRight && !this.persistent.has(this.activeRight)) this.close('right');
135
+ if (this.activeLeft && !this.persistent.has(this.activeLeft)) this.close('left');
106
136
  }
107
137
  });
108
138
 
@@ -130,6 +160,29 @@ class WidgetManager {
130
160
  } catch (e) { /* localStorage not available */ }
131
161
  }
132
162
 
163
+ /**
164
+ * Whether a widget should be showing, as far as the reader's own choices go:
165
+ * what they last decided, or the default if they have not decided anything.
166
+ */
167
+ wantsToBeOpen(widgetName) {
168
+ let saved;
169
+ try {
170
+ saved = localStorage.getItem(`ursa-widget-${widgetName}`);
171
+ } catch (e) { /* localStorage not available */ }
172
+
173
+ return saved === 'open' || (saved == null && this.defaultOpen.has(widgetName));
174
+ }
175
+
176
+ /**
177
+ * The widget that owns a side of the nav when nothing else is using it.
178
+ */
179
+ residentOf(side) {
180
+ for (const widgetName of this.persistent) {
181
+ if (this.getSide(widgetName) === side) return widgetName;
182
+ }
183
+ return null;
184
+ }
185
+
133
186
  /**
134
187
  * Restore widget states from localStorage.
135
188
  * For widgets with no saved state, use their default (defaultOpen set).
@@ -140,19 +193,24 @@ class WidgetManager {
140
193
  this.buttons.forEach(btn => widgetNames.add(btn.dataset.widget));
141
194
 
142
195
  for (const widgetName of widgetNames) {
143
- const key = `ursa-widget-${widgetName}`;
144
- let saved;
145
- try {
146
- saved = localStorage.getItem(key);
147
- } catch (e) { /* localStorage not available */ }
148
-
149
- const shouldOpen = saved === 'open' || (saved === null && this.defaultOpen.has(widgetName));
150
- if (shouldOpen) {
196
+ if (this.wantsToBeOpen(widgetName) && this.hasContent(widgetName)) {
151
197
  this.open(widgetName);
152
198
  }
153
199
  }
154
200
  }
155
201
 
202
+ /**
203
+ * Whether a widget has anything to show. A widget that has hidden its own
204
+ * button has nothing — the TOC generator does exactly that on a page with no
205
+ * headings — and restoring it would put an empty panel on screen with no
206
+ * button to shut it again. Only relevant on restore: a widget the reader
207
+ * opens by hand plainly has a button to have clicked.
208
+ */
209
+ hasContent(widgetName) {
210
+ const btn = document.querySelector(`.widget-button[data-widget="${widgetName}"]`);
211
+ return !btn || btn.style.display !== 'none';
212
+ }
213
+
156
214
  /**
157
215
  * Toggle a widget open/closed.
158
216
  */
@@ -178,8 +236,12 @@ class WidgetManager {
178
236
  const currentActive = this.getActive(side);
179
237
  if (currentActive) {
180
238
  this.deactivateContent(currentActive);
181
- // Save the closed widget's state
182
- this.saveState(currentActive, false);
239
+ // Save the closed widget's state — unless it is only lending its side out,
240
+ // in which case it has not been closed and should not be written down as
241
+ // closed. close() hands the side back when the borrower is done.
242
+ if (!this.persistent.has(currentActive)) {
243
+ this.saveState(currentActive, false);
244
+ }
183
245
  }
184
246
 
185
247
  this.setActive(side, widgetName);
@@ -233,6 +295,17 @@ class WidgetManager {
233
295
 
234
296
  // Fire event
235
297
  document.dispatchEvent(new CustomEvent('widget-closed', { detail: { widget: active, side } }));
298
+
299
+ // Give the side back to its resident, if it has one and the reader has not
300
+ // put it away themselves. Guarded against the resident being the very thing
301
+ // just closed — otherwise closing the TOC would reopen it. (It cannot
302
+ // recurse either way: the side is already empty by this point, so the open()
303
+ // below finds nothing to displace.)
304
+ const resident = this.residentOf(side);
305
+ if (resident && resident !== active &&
306
+ this.wantsToBeOpen(resident) && this.hasContent(resident)) {
307
+ this.open(resident);
308
+ }
236
309
  }
237
310
 
238
311
  /**
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@kenjura/ursa",
3
3
  "author": "Andrew London <andrew@kenjura.com>",
4
4
  "type": "module",
5
- "version": "0.90.0",
5
+ "version": "0.93.0",
6
6
  "description": "static site generator from MD/wikitext/YML",
7
7
  "main": "lib/index.js",
8
8
  "bin": {
@@ -44,13 +44,14 @@
44
44
  "remark-directive": "^4.0.0",
45
45
  "remark-gfm": "^4.0.1",
46
46
  "remark-supersub": "^1.0.0",
47
- "sharp": "^0.33.2",
47
+ "sharp": "^0.34.5",
48
48
  "unist-util-visit": "^5.1.0",
49
49
  "ws": "^8.19.0",
50
50
  "yaml": "^2.1.3",
51
51
  "yargs": "^17.7.2"
52
52
  },
53
53
  "devDependencies": {
54
+ "@tabler/icons": "^3.46.0",
54
55
  "jest": "^29.6.2",
55
56
  "node-static": "^0.7.11",
56
57
  "nodemon": "^2.0.15"
@@ -79,7 +80,8 @@
79
80
  ],
80
81
  "pnpm": {
81
82
  "onlyBuiltDependencies": [
82
- "esbuild"
83
+ "esbuild",
84
+ "sharp"
83
85
  ]
84
86
  },
85
87
  "publishConfig": {
package/src/dev.js CHANGED
@@ -428,7 +428,7 @@ async function renderDocument(urlPath) {
428
428
  }
429
429
 
430
430
  // Inject breadcrumbs before the H1
431
- const breadcrumbs = generateBreadcrumbs(dir, base, fileMeta);
431
+ const breadcrumbs = generateBreadcrumbs(dir, base, fileMeta, source);
432
432
  if (breadcrumbs) {
433
433
  body = breadcrumbs + body;
434
434
  }
@@ -0,0 +1,71 @@
1
+ import { join } from "path";
2
+ import { mkdtemp, mkdir, writeFile, rm } from "fs/promises";
3
+ import { tmpdir } from "os";
4
+ import { generateBreadcrumbs } from "../breadcrumbs.js";
5
+ import { clearConfigCache } from "../folderConfig.js";
6
+
7
+ let source;
8
+ beforeEach(async () => {
9
+ source = await mkdtemp(join(tmpdir(), "ursa-breadcrumbs-"));
10
+ clearConfigCache();
11
+ });
12
+ afterEach(async () => {
13
+ await rm(source, { recursive: true, force: true });
14
+ clearConfigCache();
15
+ });
16
+
17
+ describe("generateBreadcrumbs", () => {
18
+ it("names folder segments the way the menu does", async () => {
19
+ await mkdir(join(source, "campaigns", "bnw"), { recursive: true });
20
+ await writeFile(
21
+ join(source, "campaigns", "bnw", "index.md"),
22
+ "---\nmenu-label: 'BNW - Brave New World'\n---\n# Brave New World\n"
23
+ );
24
+
25
+ const html = generateBreadcrumbs("campaigns/bnw/", "quests", null, source);
26
+
27
+ expect(html).toContain(">BNW - Brave New World</a>");
28
+ expect(html).toContain('aria-current="page">Quests</span>');
29
+ });
30
+
31
+ it("falls back to config.json when a folder has no index", async () => {
32
+ await mkdir(join(source, "campaigns", "hfr"), { recursive: true });
33
+ await writeFile(
34
+ join(source, "campaigns", "hfr", "config.json"),
35
+ JSON.stringify({ label: "HFR - Hyacinth: Fury Road" })
36
+ );
37
+
38
+ const html = generateBreadcrumbs("campaigns/hfr/", "sessions", null, source);
39
+
40
+ expect(html).toContain(">HFR - Hyacinth: Fury Road</a>");
41
+ });
42
+
43
+ it("uses the folder label for the current crumb on a folder's index page", async () => {
44
+ await mkdir(join(source, "campaigns", "bnw"), { recursive: true });
45
+ await writeFile(
46
+ join(source, "campaigns", "bnw", "index.md"),
47
+ "---\nmenu-label: 'BNW - Brave New World'\n---\n# Brave New World\n"
48
+ );
49
+
50
+ const html = generateBreadcrumbs("campaigns/bnw/", "index", null, source);
51
+
52
+ expect(html).toContain('aria-current="page">BNW - Brave New World</span>');
53
+ });
54
+
55
+ it("keeps the document's own frontmatter override for the last crumb", () => {
56
+ const withMenuLabel = generateBreadcrumbs("guides/", "setup", {
57
+ "menu-label": "Getting Started",
58
+ title: "Setup Guide",
59
+ });
60
+ expect(withMenuLabel).toContain('aria-current="page">Getting Started</span>');
61
+
62
+ // menu-label absent: title still wins, as it always has
63
+ const withTitle = generateBreadcrumbs("guides/", "setup", { title: "Setup Guide" });
64
+ expect(withTitle).toContain('aria-current="page">Setup Guide</span>');
65
+ });
66
+
67
+ it("preserves interior capitalization without a source root", () => {
68
+ const html = generateBreadcrumbs("campaigns/SoL/", "index", null);
69
+ expect(html).toContain('aria-current="page">SoL</span>');
70
+ });
71
+ });
@@ -0,0 +1,114 @@
1
+ import { join } from "path";
2
+ import { mkdtemp, rm, mkdir, writeFile, readFile } from "fs/promises";
3
+ import { existsSync } from "fs";
4
+ import { tmpdir } from "os";
5
+ import {
6
+ enforceCacheVersion,
7
+ getUrsaDir,
8
+ loadHashCache,
9
+ saveHashCache,
10
+ } from "../contentHash.js";
11
+
12
+ let sourceDir;
13
+ beforeEach(async () => {
14
+ sourceDir = await mkdtemp(join(tmpdir(), "ursa-cachestamp-"));
15
+ });
16
+ afterEach(async () => {
17
+ await rm(sourceDir, { recursive: true, force: true });
18
+ });
19
+
20
+ /** Write a populated `.ursa/` as an older build of ursa would have left it. */
21
+ async function seedCache(stampVersion) {
22
+ const ursaDir = getUrsaDir(sourceDir);
23
+ await mkdir(ursaDir, { recursive: true });
24
+ await saveHashCache(sourceDir, new Map([["/site/a.md", "abc123"]]));
25
+ if (stampVersion !== null) {
26
+ await writeFile(
27
+ join(ursaDir, "cache-stamp.json"),
28
+ JSON.stringify({ ursaVersion: stampVersion })
29
+ );
30
+ }
31
+ }
32
+
33
+ describe("enforceCacheVersion", () => {
34
+ it("stamps a first build without reporting a reset", async () => {
35
+ const result = await enforceCacheVersion(sourceDir, "1.0.0");
36
+
37
+ expect(result).toEqual({ reset: false, previous: null, version: "1.0.0" });
38
+ const stamp = JSON.parse(
39
+ await readFile(join(getUrsaDir(sourceDir), "cache-stamp.json"), "utf8")
40
+ );
41
+ expect(stamp.ursaVersion).toBe("1.0.0");
42
+ });
43
+
44
+ it("keeps the cache when the stamp matches", async () => {
45
+ await seedCache("1.0.0");
46
+
47
+ const result = await enforceCacheVersion(sourceDir, "1.0.0");
48
+
49
+ expect(result.reset).toBe(false);
50
+ expect(await loadHashCache(sourceDir)).toEqual(
51
+ new Map([["/site/a.md", "abc123"]])
52
+ );
53
+ });
54
+
55
+ it("discards the cache when ursa has been upgraded", async () => {
56
+ await seedCache("1.0.0");
57
+
58
+ const result = await enforceCacheVersion(sourceDir, "1.1.0");
59
+
60
+ expect(result).toEqual({ reset: true, previous: "1.0.0", version: "1.1.0" });
61
+ expect(await loadHashCache(sourceDir)).toEqual(new Map());
62
+ });
63
+
64
+ it("discards the cache when ursa has been downgraded", async () => {
65
+ await seedCache("1.1.0");
66
+
67
+ const result = await enforceCacheVersion(sourceDir, "1.0.0");
68
+
69
+ expect(result.reset).toBe(true);
70
+ expect(await loadHashCache(sourceDir)).toEqual(new Map());
71
+ });
72
+
73
+ it("discards a cache left by a version that predates stamping", async () => {
74
+ await seedCache(null);
75
+
76
+ const result = await enforceCacheVersion(sourceDir, "1.0.0");
77
+
78
+ expect(result).toEqual({ reset: true, previous: null, version: "1.0.0" });
79
+ expect(await loadHashCache(sourceDir)).toEqual(new Map());
80
+ });
81
+
82
+ it("discards the cache when the stamp is unreadable", async () => {
83
+ await seedCache("1.0.0");
84
+ await writeFile(join(getUrsaDir(sourceDir), "cache-stamp.json"), "not json");
85
+
86
+ const result = await enforceCacheVersion(sourceDir, "1.0.0");
87
+
88
+ expect(result.reset).toBe(true);
89
+ expect(await loadHashCache(sourceDir)).toEqual(new Map());
90
+ });
91
+
92
+ it("leaves a stamp that the next run accepts", async () => {
93
+ await seedCache("1.0.0");
94
+
95
+ await enforceCacheVersion(sourceDir, "1.1.0");
96
+ await saveHashCache(sourceDir, new Map([["/site/a.md", "def456"]]));
97
+ const second = await enforceCacheVersion(sourceDir, "1.1.0");
98
+
99
+ expect(second.reset).toBe(false);
100
+ expect(await loadHashCache(sourceDir)).toEqual(
101
+ new Map([["/site/a.md", "def456"]])
102
+ );
103
+ });
104
+
105
+ it("removes every cache file in .ursa, not just the hashes", async () => {
106
+ await seedCache("1.0.0");
107
+ const navCache = join(getUrsaDir(sourceDir), "nav-cache.json");
108
+ await writeFile(navCache, JSON.stringify({ stale: true }));
109
+
110
+ await enforceCacheVersion(sourceDir, "1.1.0");
111
+
112
+ expect(existsSync(navCache)).toBe(false);
113
+ });
114
+ });
@@ -3,8 +3,15 @@ import { isHiddenOrSystemPath } from "./hiddenPaths.js";
3
3
  import { extname, basename, join, dirname } from "path";
4
4
  import { existsSync, readFileSync } from "fs";
5
5
  import { getFolderConfig, isFolderHidden, getRootConfig } from "./folderConfig.js";
6
- import { extractMetadata, isMetadataOnly } from "./metadataExtractor.js";
7
- import { stripHtml } from "./stripHtml.js";
6
+ import { isMetadataOnly } from "./metadataExtractor.js";
7
+ import {
8
+ INDEX_EXTENSIONS,
9
+ toDisplayName,
10
+ getMenuLabelFromFile,
11
+ getMenuSortAsFromFile,
12
+ getFolderLabel,
13
+ getFolderSortKey,
14
+ } from "./menuLabels.js";
8
15
 
9
16
  // Icon extensions to check for custom icons
10
17
  const ICON_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.ico'];
@@ -14,93 +21,6 @@ const FOLDER_ICON = '📁';
14
21
  const DOCUMENT_ICON = '📄';
15
22
  const HOME_ICON = '🏠';
16
23
 
17
- // Index file extensions to check for folder links
18
- const INDEX_EXTENSIONS = ['.md', '.mdx', '.txt', '.yml', '.yaml'];
19
-
20
- // Convert filename to display name (e.g., "foo-bar" -> "Foo Bar")
21
- function toDisplayName(filename) {
22
- return filename
23
- .replace(/[-_]/g, ' ') // Replace dashes and underscores with spaces
24
- .replace(/\b\w/g, c => c.toUpperCase()); // Capitalize first letter of each word
25
- }
26
-
27
- /**
28
- * Get the menu label from a file's frontmatter
29
- * @param {string} filePath - Path to the markdown file
30
- * @returns {string|null} The menu-label value (with HTML stripped), or null if not found
31
- */
32
- function getMenuLabelFromFile(filePath) {
33
- try {
34
- if (!existsSync(filePath)) return null;
35
- const content = readFileSync(filePath, 'utf8');
36
- const metadata = extractMetadata(content);
37
- if (metadata && metadata['menu-label']) {
38
- return stripHtml(String(metadata['menu-label']));
39
- }
40
- } catch (e) {
41
- // Ignore read errors
42
- }
43
- return null;
44
- }
45
-
46
- /**
47
- * Get the menu-sort-as value from a file's frontmatter
48
- * @param {string} filePath - Path to the markdown file
49
- * @returns {string|null} The menu-sort-as value (with HTML stripped), or null if not found
50
- */
51
- function getMenuSortAsFromFile(filePath) {
52
- try {
53
- if (!existsSync(filePath)) return null;
54
- const content = readFileSync(filePath, 'utf8');
55
- const metadata = extractMetadata(content);
56
- if (metadata && metadata['menu-sort-as']) {
57
- return stripHtml(String(metadata['menu-sort-as']));
58
- }
59
- } catch (e) {
60
- // Ignore read errors
61
- }
62
- return null;
63
- }
64
-
65
- /**
66
- * Get the menu label for a folder from its index.md frontmatter
67
- * Falls back to config.json label (deprecated), then display name
68
- * @param {string} dirPath - Path to the folder
69
- * @param {object|null} folderConfig - The folder's config.json if any
70
- * @param {string} baseName - The folder's base name
71
- * @returns {string} The label to display
72
- */
73
- function getFolderLabel(dirPath, folderConfig, baseName) {
74
- // First, check index.md for menu-label (preferred method)
75
- for (const ext of INDEX_EXTENSIONS) {
76
- const indexPath = join(dirPath, `index${ext}`);
77
- const label = getMenuLabelFromFile(indexPath);
78
- if (label) return label;
79
- }
80
-
81
- // Fall back to config.json label (deprecated)
82
- if (folderConfig?.label) {
83
- return folderConfig.label;
84
- }
85
-
86
- // Default to display name from folder name
87
- return toDisplayName(baseName);
88
- }
89
-
90
- /**
91
- * Get the sort key for a folder from its index.md frontmatter
92
- * @param {string} dirPath - Path to the folder
93
- * @returns {string|null} The menu-sort-as value, or null if not found
94
- */
95
- function getFolderSortKey(dirPath) {
96
- for (const ext of INDEX_EXTENSIONS) {
97
- const indexPath = join(dirPath, `index${ext}`);
98
- const sortKey = getMenuSortAsFromFile(indexPath);
99
- if (sortKey) return sortKey;
100
- }
101
- return null;
102
- }
103
-
104
24
  /**
105
25
  * Check if a file is an index file
106
26
  * @param {string} baseName - The file's base name (without extension)
@@ -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,7 @@ 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
6
 
7
7
  let tempDir;
8
8
  let source;
@@ -93,3 +93,70 @@ describe("generateAutoIndices with empty source folders", () => {
93
93
  expect(rootIndex).toContain('<a href="docs/index.html">');
94
94
  });
95
95
  });
96
+
97
+ describe("auto-index naming matches the automenu", () => {
98
+ it("uses menu-label from a folder's index frontmatter, and config.json when there is no index", async () => {
99
+ // bnw has a real index.md carrying the label; hfr has no index at all, so
100
+ // it falls back to config.json — the same two-step the automenu uses.
101
+ await mkdir(join(source, "bnw"));
102
+ await writeFile(
103
+ join(source, "bnw", "index.md"),
104
+ "---\nmenu-label: 'BNW - Brave New World'\n---\n"
105
+ );
106
+ await writeFile(join(source, "bnw", "quests.md"), "# Quests\n");
107
+ await mkdir(join(source, "hfr"));
108
+ await writeFile(
109
+ join(source, "hfr", "config.json"),
110
+ JSON.stringify({ label: "HFR - Hyacinth: Fury Road" })
111
+ );
112
+ await writeFile(join(source, "hfr", "hfr.md"), "# Hyacinth\n");
113
+
114
+ const html = await generateAutoIndexHtmlFromSource(source, 1);
115
+
116
+ expect(html).toContain('<a href="bnw/index.html">BNW - Brave New World</a>');
117
+ expect(html).toContain('<a href="hfr/index.html">HFR - Hyacinth: Fury Road</a>');
118
+ });
119
+
120
+ it("uses menu-label on individual documents and menu-sort-as for ordering", async () => {
121
+ await writeFile(
122
+ join(source, "zebra.md"),
123
+ "---\nmenu-label: 'ZED - Zebra'\nmenu-sort-as: 'aardvark'\n---\n# Zebra\n"
124
+ );
125
+ await writeFile(join(source, "middle.md"), "# Middle\n");
126
+
127
+ const html = await generateAutoIndexHtmlFromSource(source, 1);
128
+
129
+ expect(html).toContain('<a href="zebra.html">ZED - Zebra</a>');
130
+ // Sorted by menu-sort-as ("aardvark"), not by filename ("zebra")
131
+ expect(html.indexOf("ZED - Zebra")).toBeLessThan(html.indexOf("Middle"));
132
+ });
133
+
134
+ it("preserves interior capitalization instead of title-casing it away", async () => {
135
+ await mkdir(join(source, "SoL"));
136
+ await writeFile(join(source, "SoL", "notes.md"), "# Notes\n");
137
+
138
+ const html = await generateAutoIndexHtmlFromSource(source, 1);
139
+
140
+ expect(html).toContain('<a href="SoL/index.html">SoL</a>');
141
+ });
142
+
143
+ it("labels generated index pages with the folder's menu-label", async () => {
144
+ // No index.md content, so this folder gets an auto-generated index.html;
145
+ // its <h1> should use the label rather than the raw folder name.
146
+ await mkdir(join(source, "bnw"));
147
+ await writeFile(
148
+ join(source, "bnw", "config.json"),
149
+ JSON.stringify({ label: "BNW - Brave New World" })
150
+ );
151
+ await writeFile(join(source, "bnw", "quests.md"), "# Quests\n");
152
+ await mkdir(join(output, "bnw"));
153
+ await writeFile(join(output, "bnw", "quests.html"), "<html><body>Quests</body></html>");
154
+
155
+ await runAutoIndices([join(source, "bnw")], [join(source, "bnw", "quests.md")], makeProgress());
156
+
157
+ const bnwIndex = await readFile(join(output, "bnw", "index.html"), "utf8");
158
+ expect(bnwIndex).toContain("<h1>BNW - Brave New World</h1>");
159
+ const rootIndex = await readFile(join(output, "index.html"), "utf8");
160
+ expect(rootIndex).toContain('<a href="bnw/index.html">BNW - Brave New World</a>');
161
+ });
162
+ });