@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.
@@ -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.1",
5
+ "version": "0.95.0",
6
6
  "description": "static site generator from MD/wikitext/YML",
7
7
  "main": "lib/index.js",
8
8
  "bin": {
@@ -51,6 +51,7 @@
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"
package/src/dev.js CHANGED
@@ -372,6 +372,15 @@ async function renderDocument(urlPath) {
372
372
  }
373
373
 
374
374
  const { sourcePath, type } = resolved;
375
+
376
+ // `hidden: true` means the folder takes no part in the build. `generate`
377
+ // writes nothing for it, so serving it on demand would make `serve` and
378
+ // `generate` disagree — the page works all through development and 404s in
379
+ // production, the exact failure mode this is meant to prevent.
380
+ if (isFolderHidden(dirname(sourcePath), source)) {
381
+ return null;
382
+ }
383
+
375
384
  const ext = type;
376
385
  const base = basename(sourcePath, ext);
377
386
  const dir = addTrailingSlash(dirname(sourcePath)).replace(source, "");
@@ -428,7 +437,7 @@ async function renderDocument(urlPath) {
428
437
  }
429
438
 
430
439
  // Inject breadcrumbs before the H1
431
- const breadcrumbs = generateBreadcrumbs(dir, base, fileMeta);
440
+ const breadcrumbs = generateBreadcrumbs(dir, base, fileMeta, source);
432
441
  if (breadcrumbs) {
433
442
  body = breadcrumbs + body;
434
443
  }
@@ -834,6 +843,26 @@ export async function dev({
834
843
  app.use(async (req, res, next) => {
835
844
  const url = req.url;
836
845
 
846
+ // Nothing under a folder that config.json marks `hidden: true` is served.
847
+ //
848
+ // This gate sits ahead of everything, including the `express.static`
849
+ // fallbacks mounted below — falling through with `next()` would just hand
850
+ // the file to them. `generate` writes no output for a hidden folder, so
851
+ // anything served here would work all through development and 404 in
852
+ // production, which is precisely what the setting exists to avoid.
853
+ let requestedSourcePath = null;
854
+ try {
855
+ requestedSourcePath = join(sourceDir, decodeURIComponent(url.split('?')[0]));
856
+ } catch (e) {
857
+ // Malformed percent-encoding — not a path we can classify; let it fall through
858
+ }
859
+ // Tested against the path itself, not its parent: `/foo/_art/` and
860
+ // `/foo/_art/index.html` must both be refused, and a file simply has no
861
+ // config.json of its own, so the ancestors decide either way.
862
+ if (requestedSourcePath && isFolderHidden(requestedSourcePath, sourceDir)) {
863
+ return res.status(404).send('<h1>404 Not Found</h1>');
864
+ }
865
+
837
866
  // Handle search index requests
838
867
  if (url === '/public/search-index.json' || url === '/public/fulltext-index.json') {
839
868
  if (!devState.searchReady) {
@@ -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
+ });
@@ -0,0 +1,89 @@
1
+ import { join } from "path";
2
+ import { mkdtemp, mkdir, writeFile, rm } from "fs/promises";
3
+ import { tmpdir } from "os";
4
+ import {
5
+ clearConfigCache,
6
+ isFolderHidden,
7
+ isFolderSelfHidden,
8
+ } from "../folderConfig.js";
9
+
10
+ let source;
11
+ beforeEach(async () => {
12
+ source = await mkdtemp(join(tmpdir(), "ursa-folderconfig-"));
13
+ clearConfigCache();
14
+ });
15
+ afterEach(async () => {
16
+ await rm(source, { recursive: true, force: true });
17
+ });
18
+
19
+ async function hide(...segments) {
20
+ const dir = join(source, ...segments);
21
+ await mkdir(dir, { recursive: true });
22
+ await writeFile(join(dir, "config.json"), JSON.stringify({ hidden: true }));
23
+ return dir;
24
+ }
25
+
26
+ describe("isFolderHidden", () => {
27
+ it("matches the hidden folder itself", async () => {
28
+ const art = await hide("everdew", "_art");
29
+ expect(isFolderHidden(art, source)).toBe(true);
30
+ });
31
+
32
+ it("matches a descendant folder of a hidden folder", async () => {
33
+ await hide("everdew", "_art");
34
+ const nested = join(source, "everdew", "_art", "prompts", "people");
35
+ await mkdir(nested, { recursive: true });
36
+ expect(isFolderHidden(nested, source)).toBe(true);
37
+ });
38
+
39
+ it("matches file paths, not just directories", async () => {
40
+ // The build filters one list holding both files and directories through
41
+ // this predicate, so a file must resolve via its ancestors.
42
+ await hide("everdew", "_art");
43
+ expect(
44
+ isFolderHidden(join(source, "everdew", "_art", "prompts.md"), source)
45
+ ).toBe(true);
46
+ expect(
47
+ isFolderHidden(join(source, "everdew", "_art", "img", "map.png"), source)
48
+ ).toBe(true);
49
+ });
50
+
51
+ it("leaves siblings and ancestors of a hidden folder visible", async () => {
52
+ await hide("everdew", "_art");
53
+ await mkdir(join(source, "everdew", "people"), { recursive: true });
54
+ expect(isFolderHidden(join(source, "everdew", "people"), source)).toBe(false);
55
+ expect(isFolderHidden(join(source, "everdew"), source)).toBe(false);
56
+ expect(
57
+ isFolderHidden(join(source, "everdew", "people", "alice.md"), source)
58
+ ).toBe(false);
59
+ });
60
+
61
+ it("ignores a config.json that does not set hidden", async () => {
62
+ const dir = join(source, "everdew");
63
+ await mkdir(dir, { recursive: true });
64
+ await writeFile(join(dir, "config.json"), JSON.stringify({ label: "Everdew" }));
65
+ expect(isFolderHidden(dir, source)).toBe(false);
66
+ });
67
+
68
+ it("tolerates a trailing slash on the docroot", async () => {
69
+ const art = await hide("everdew", "_art");
70
+ expect(isFolderHidden(art, source + "/")).toBe(true);
71
+ });
72
+ });
73
+
74
+ describe("isFolderSelfHidden", () => {
75
+ it("is true only for the folder carrying the config, not its descendants", async () => {
76
+ const art = await hide("everdew", "_art");
77
+ const nested = join(art, "prompts");
78
+ await mkdir(nested, { recursive: true });
79
+
80
+ expect(isFolderSelfHidden(art)).toBe(true);
81
+ expect(isFolderSelfHidden(nested)).toBe(false);
82
+ });
83
+
84
+ it("is false for a folder with no config.json", async () => {
85
+ const dir = join(source, "people");
86
+ await mkdir(dir, { recursive: true });
87
+ expect(isFolderSelfHidden(dir)).toBe(false);
88
+ });
89
+ });
@@ -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)
@@ -298,8 +218,10 @@ function buildMenuData(tree, source, validPaths, parentPath = '', includeDebug =
298
218
  }
299
219
  }
300
220
 
301
- // Check if this folder is hidden via config.json
302
- if (hasChildren && isFolderHidden(item.path, source)) {
221
+ // Check if this folder is hidden via config.json.
222
+ // Not gated on hasChildren: a hidden folder is ignored whether or not the
223
+ // tree walker found children under it.
224
+ if (isFolderHidden(item.path, source)) {
303
225
  continue; // Skip hidden folders
304
226
  }
305
227