@kenjura/ursa 0.72.0 → 0.75.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.
@@ -0,0 +1,376 @@
1
+ /**
2
+ * Widget system for the top nav right-side panel.
3
+ *
4
+ * Widgets appear as icon buttons in the nav bar. Clicking a button toggles a
5
+ * dropdown panel anchored to the right side below the nav. Only one widget can
6
+ * be open at a time.
7
+ *
8
+ * Built-in widgets: TOC, Search, Profile
9
+ */
10
+ class WidgetManager {
11
+ constructor() {
12
+ this.dropdown = document.getElementById('widget-dropdown');
13
+ this.buttons = document.querySelectorAll('.widget-button[data-widget]');
14
+ this.activeWidget = null;
15
+
16
+ if (!this.dropdown || this.buttons.length === 0) return;
17
+
18
+ this.init();
19
+ }
20
+
21
+ init() {
22
+ // Bind button clicks
23
+ this.buttons.forEach(btn => {
24
+ btn.addEventListener('click', (e) => {
25
+ e.stopPropagation();
26
+ const widgetName = btn.dataset.widget;
27
+ this.toggle(widgetName);
28
+ });
29
+ });
30
+
31
+ // Close on outside click
32
+ document.addEventListener('click', (e) => {
33
+ if (this.activeWidget &&
34
+ !this.dropdown.contains(e.target) &&
35
+ !e.target.closest('.widget-button')) {
36
+ this.close();
37
+ }
38
+ });
39
+
40
+ // Close on Escape
41
+ document.addEventListener('keydown', (e) => {
42
+ if (e.key === 'Escape' && this.activeWidget) {
43
+ this.close();
44
+ }
45
+ });
46
+
47
+ // Initialize search widget content
48
+ this.initSearchWidget();
49
+ }
50
+
51
+ /**
52
+ * Toggle a widget open/closed. If a different widget is open, switch to the new one.
53
+ */
54
+ toggle(widgetName) {
55
+ if (this.activeWidget === widgetName) {
56
+ this.close();
57
+ return;
58
+ }
59
+
60
+ this.open(widgetName);
61
+ }
62
+
63
+ /**
64
+ * Open a specific widget panel.
65
+ */
66
+ open(widgetName) {
67
+ // Close any open widget first
68
+ if (this.activeWidget) {
69
+ this.deactivateContent(this.activeWidget);
70
+ }
71
+
72
+ this.activeWidget = widgetName;
73
+
74
+ // Show dropdown
75
+ this.dropdown.classList.remove('hidden');
76
+ this.dropdown.dataset.activeWidget = widgetName;
77
+
78
+ // Show the correct content panel
79
+ this.activateContent(widgetName);
80
+
81
+ // Update button states
82
+ this.buttons.forEach(btn => {
83
+ btn.classList.toggle('active', btn.dataset.widget === widgetName);
84
+ });
85
+
86
+ // Fire event for other scripts to listen to
87
+ document.dispatchEvent(new CustomEvent('widget-opened', { detail: { widget: widgetName } }));
88
+ }
89
+
90
+ /**
91
+ * Close the currently open widget.
92
+ */
93
+ close() {
94
+ if (!this.activeWidget) return;
95
+
96
+ const closing = this.activeWidget;
97
+ this.deactivateContent(closing);
98
+
99
+ this.activeWidget = null;
100
+ this.dropdown.classList.add('hidden');
101
+ delete this.dropdown.dataset.activeWidget;
102
+
103
+ // Update button states
104
+ this.buttons.forEach(btn => btn.classList.remove('active'));
105
+
106
+ // Fire event
107
+ document.dispatchEvent(new CustomEvent('widget-closed', { detail: { widget: closing } }));
108
+ }
109
+
110
+ /**
111
+ * Show a widget's content panel.
112
+ */
113
+ activateContent(widgetName) {
114
+ const content = this.dropdown.querySelector(`.widget-content[data-widget="${widgetName}"]`);
115
+ if (content) {
116
+ content.classList.add('active');
117
+ }
118
+
119
+ // Widget-specific activation
120
+ if (widgetName === 'search') {
121
+ this.activateSearch();
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Hide a widget's content panel.
127
+ */
128
+ deactivateContent(widgetName) {
129
+ const content = this.dropdown.querySelector(`.widget-content[data-widget="${widgetName}"]`);
130
+ if (content) {
131
+ content.classList.remove('active');
132
+ }
133
+
134
+ // Widget-specific deactivation
135
+ if (widgetName === 'search') {
136
+ this.deactivateSearch();
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Initialize search widget — move the search input and results into the widget panel.
142
+ */
143
+ initSearchWidget() {
144
+ const searchContent = document.getElementById('widget-content-search');
145
+ if (!searchContent) return;
146
+
147
+ // The search input and wrapper are created by search.js (GlobalSearch).
148
+ // We need to wait for it to be ready, then move elements into the widget.
149
+ // Use a short delay to let GlobalSearch initialize first.
150
+ const moveSearch = () => {
151
+ const searchWrapper = document.querySelector('.search-wrapper-inline');
152
+ const searchResults = document.getElementById('search-results');
153
+
154
+ if (searchWrapper) {
155
+ // Clone the search input into the widget (the inline one stays for non-top-menu/mobile)
156
+ // Actually, we'll relocate the existing elements when the widget is activated.
157
+ // For now, create a dedicated search input for the widget.
158
+ const widgetInput = document.createElement('input');
159
+ widgetInput.id = 'widget-search-input';
160
+ widgetInput.type = 'text';
161
+ widgetInput.placeholder = 'Search...';
162
+ widgetInput.className = 'widget-search-input';
163
+
164
+ const widgetWrapper = document.createElement('div');
165
+ widgetWrapper.className = 'widget-search-wrapper';
166
+ widgetWrapper.appendChild(widgetInput);
167
+
168
+ // Create dedicated results container for widget
169
+ const widgetResults = document.createElement('div');
170
+ widgetResults.id = 'widget-search-results';
171
+ widgetResults.className = 'widget-search-results';
172
+
173
+ searchContent.appendChild(widgetWrapper);
174
+ searchContent.appendChild(widgetResults);
175
+
176
+ // Bind the widget search input to the GlobalSearch instance
177
+ this.bindWidgetSearch(widgetInput, widgetResults);
178
+ }
179
+ };
180
+
181
+ // Wait for search.js to initialize
182
+ setTimeout(moveSearch, 50);
183
+ }
184
+
185
+ /**
186
+ * Bind the widget search input to use GlobalSearch's search functionality.
187
+ */
188
+ bindWidgetSearch(input, resultsContainer) {
189
+ this._widgetSearchInput = input;
190
+ this._widgetSearchResults = resultsContainer;
191
+
192
+ let currentSelection = -1;
193
+
194
+ input.addEventListener('input', () => {
195
+ const query = input.value.trim();
196
+ this.performWidgetSearch(query);
197
+ });
198
+
199
+ input.addEventListener('keydown', (e) => {
200
+ const items = resultsContainer.querySelectorAll('.search-result-item');
201
+
202
+ switch (e.key) {
203
+ case 'ArrowDown':
204
+ e.preventDefault();
205
+ if (items.length > 0) {
206
+ currentSelection = Math.min(currentSelection + 1, items.length - 1);
207
+ this.updateWidgetSearchSelection(items, currentSelection);
208
+ }
209
+ break;
210
+ case 'ArrowUp':
211
+ e.preventDefault();
212
+ if (items.length > 0) {
213
+ currentSelection = Math.max(currentSelection - 1, 0);
214
+ this.updateWidgetSearchSelection(items, currentSelection);
215
+ }
216
+ break;
217
+ case 'Enter':
218
+ e.preventDefault();
219
+ if (currentSelection >= 0 && items[currentSelection]) {
220
+ items[currentSelection].click();
221
+ }
222
+ break;
223
+ case 'Escape':
224
+ this.close();
225
+ break;
226
+ }
227
+ });
228
+
229
+ // Reset selection on new search
230
+ input.addEventListener('input', () => { currentSelection = -1; });
231
+ }
232
+
233
+ /**
234
+ * Perform search using GlobalSearch's search logic, rendering into widget results.
235
+ */
236
+ performWidgetSearch(query) {
237
+ const gs = window.globalSearch;
238
+ const container = this._widgetSearchResults;
239
+ if (!gs || !container) return;
240
+
241
+ container.innerHTML = '';
242
+
243
+ if (!query || query.length < gs.MIN_QUERY_LENGTH) {
244
+ if (query && query.length > 0) {
245
+ container.innerHTML = `<div class="search-result-message">Type at least ${gs.MIN_QUERY_LENGTH} characters to search</div>`;
246
+ }
247
+ return;
248
+ }
249
+
250
+ if (!gs.indexLoaded) {
251
+ container.innerHTML = '<div class="search-result-message">Loading search index...</div>';
252
+ return;
253
+ }
254
+
255
+ const pathResults = gs.searchPaths(query);
256
+ const fullTextResults = gs.searchFullText(query);
257
+
258
+ // Deduplicate
259
+ const pathPaths = new Set(pathResults.map(r => r.path));
260
+ const uniqueFullTextResults = fullTextResults.filter(r => !pathPaths.has(r.path));
261
+
262
+ if (pathResults.length === 0 && uniqueFullTextResults.length === 0) {
263
+ container.innerHTML = `<div class="search-result-message">No results for "${query}"</div>`;
264
+ return;
265
+ }
266
+
267
+ // Path results section
268
+ if (pathResults.length > 0) {
269
+ const section = document.createElement('div');
270
+ section.className = 'search-section';
271
+ const header = document.createElement('div');
272
+ header.className = 'search-section-header';
273
+ header.textContent = `Title/Path Matches (${pathResults.length})`;
274
+ section.appendChild(header);
275
+
276
+ const limit = Math.min(pathResults.length, 10);
277
+ for (let i = 0; i < limit; i++) {
278
+ section.appendChild(this.createWidgetResultItem(pathResults[i]));
279
+ }
280
+ if (pathResults.length > 10) {
281
+ const more = document.createElement('div');
282
+ more.className = 'search-result-message';
283
+ more.textContent = `... and ${pathResults.length - 10} more`;
284
+ section.appendChild(more);
285
+ }
286
+ container.appendChild(section);
287
+ }
288
+
289
+ // Full-text results section
290
+ if (uniqueFullTextResults.length > 0) {
291
+ const section = document.createElement('div');
292
+ section.className = 'search-section';
293
+ const header = document.createElement('div');
294
+ header.className = 'search-section-header';
295
+ header.textContent = `Content Matches (${uniqueFullTextResults.length})`;
296
+ section.appendChild(header);
297
+
298
+ const limit = Math.min(uniqueFullTextResults.length, 10);
299
+ for (let i = 0; i < limit; i++) {
300
+ section.appendChild(this.createWidgetResultItem(uniqueFullTextResults[i]));
301
+ }
302
+ if (uniqueFullTextResults.length > 10) {
303
+ const more = document.createElement('div');
304
+ more.className = 'search-result-message';
305
+ more.textContent = `... and ${uniqueFullTextResults.length - 10} more`;
306
+ section.appendChild(more);
307
+ }
308
+ container.appendChild(section);
309
+ }
310
+ }
311
+
312
+ /**
313
+ * Create a search result item for the widget.
314
+ */
315
+ createWidgetResultItem(result) {
316
+ const item = document.createElement('div');
317
+ item.className = 'search-result-item';
318
+
319
+ const title = document.createElement('div');
320
+ title.className = 'search-result-title';
321
+ title.textContent = result.title || 'Untitled';
322
+
323
+ const path = document.createElement('div');
324
+ path.className = 'search-result-path';
325
+ path.textContent = result.path || result.url || '';
326
+
327
+ item.appendChild(title);
328
+ item.appendChild(path);
329
+
330
+ item.addEventListener('click', () => {
331
+ window.location.href = result.url || result.path;
332
+ });
333
+
334
+ item.addEventListener('mouseenter', () => {
335
+ // Clear other selections
336
+ item.closest('.widget-search-results')?.querySelectorAll('.search-result-item').forEach(el => {
337
+ el.classList.remove('selected');
338
+ });
339
+ item.classList.add('selected');
340
+ });
341
+
342
+ return item;
343
+ }
344
+
345
+ updateWidgetSearchSelection(items, index) {
346
+ items.forEach((item, i) => {
347
+ item.classList.toggle('selected', i === index);
348
+ });
349
+ if (index >= 0 && items[index]) {
350
+ items[index].scrollIntoView({ block: 'nearest' });
351
+ }
352
+ }
353
+
354
+ /**
355
+ * Called when the search widget is activated.
356
+ */
357
+ activateSearch() {
358
+ const input = this._widgetSearchInput;
359
+ if (input) {
360
+ // Focus with small delay to allow panel animation
361
+ setTimeout(() => input.focus(), 50);
362
+ }
363
+ }
364
+
365
+ /**
366
+ * Called when the search widget is deactivated.
367
+ */
368
+ deactivateSearch() {
369
+ // Keep the search query so user can re-open and see results
370
+ }
371
+ }
372
+
373
+ // Initialize widgets when DOM is ready
374
+ document.addEventListener('DOMContentLoaded', () => {
375
+ window.widgetManager = new WidgetManager();
376
+ });
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.72.0",
5
+ "version": "0.75.0",
6
6
  "description": "static site generator from MD/wikitext/YML",
7
7
  "main": "lib/index.js",
8
8
  "bin": {
package/src/dev.js CHANGED
@@ -28,6 +28,7 @@ import { buildFullTextIndex } from "./helper/fullTextIndex.js";
28
28
  import { getAutomenu } from "./helper/automenu.js";
29
29
  import { renderFile } from "./helper/fileRenderer.js";
30
30
  import { processImage } from "./helper/imageProcessor.js";
31
+ import { generateBreadcrumbs } from "./helper/breadcrumbs.js";
31
32
  import { extractImageReferences } from "./helper/imageExtractor.js";
32
33
  import { recurse } from "./helper/recursive-readdir.js";
33
34
  import { isFolderHidden, clearConfigCache } from "./helper/folderConfig.js";
@@ -358,8 +359,9 @@ async function renderDocument(urlPath) {
358
359
  const sourceDir = dirname(sourcePath);
359
360
  const autoIndexHtml = await generateAutoIndexHtmlFromSource(sourceDir, 2);
360
361
  if (autoIndexHtml) {
361
- // Wrap in template and return
362
- return await wrapInTemplate(autoIndexHtml, 'Index', null, urlPath, sourcePath);
362
+ // Wrap in template and return — use parent folder name for title
363
+ const indexTitle = toTitleCase(basename(dirname(sourcePath)) || 'Index');
364
+ return await wrapInTemplate(autoIndexHtml, indexTitle, null, urlPath, sourcePath);
363
365
  }
364
366
  }
365
367
 
@@ -375,6 +377,20 @@ async function renderDocument(urlPath) {
375
377
  });
376
378
 
377
379
  const fileMeta = extractMetadata(rawBody);
380
+
381
+ // Title from filename (for index/home, use parent folder name)
382
+ const titleBase = (base === 'index' || base === 'home') ? basename(dirname(sourcePath)) : base;
383
+ const title = toTitleCase(titleBase || base);
384
+ if (!body || !body.trimStart().startsWith('<h1')) {
385
+ const h1Title = fileMeta?.title || title;
386
+ body = `<h1>${h1Title}</h1>\n` + (body || '');
387
+ }
388
+
389
+ // Inject breadcrumbs before the H1
390
+ const breadcrumbs = generateBreadcrumbs(dir, base, fileMeta);
391
+ if (breadcrumbs) {
392
+ body = breadcrumbs + body;
393
+ }
378
394
 
379
395
  // Inject frontmatter table for markdown/mdx files
380
396
  if ((type === '.md' || type === '.mdx') && fileMeta) {
@@ -401,7 +417,6 @@ async function renderDocument(urlPath) {
401
417
  // Process images in this document
402
418
  body = await processDocumentImages(body, sourcePath);
403
419
 
404
- const title = toTitleCase(base);
405
420
  const html = await wrapInTemplate(body, title, fileMeta, urlPath, sourcePath);
406
421
 
407
422
  // Cache rendered document
@@ -463,7 +478,7 @@ async function wrapInTemplate(body, title, fileMeta, urlPath, sourcePath) {
463
478
 
464
479
  // Build replacements
465
480
  const replacements = {
466
- "${title}": title,
481
+ "${title}": fileMeta?.title || title,
467
482
  "${menu}": menu,
468
483
  "${meta}": JSON.stringify(fileMeta || {}),
469
484
  "${transformedMetadata}": "",
@@ -478,16 +493,28 @@ async function wrapInTemplate(body, title, fileMeta, urlPath, sourcePath) {
478
493
  const pattern = /\$\{(title|menu|meta|transformedMetadata|body|styleLink|customScript|searchIndex|footer)\}/g;
479
494
  let finalHtml = template.replace(pattern, (match) => replacements[match] ?? match);
480
495
 
481
- // Check for custom menu
496
+ // Add menu data attributes to body
482
497
  if (sourcePath && customMenus) {
483
498
  const customMenuInfo = getCustomMenuForFile(sourcePath, source, customMenus);
484
499
  if (customMenuInfo) {
485
- const menuPosition = customMenuInfo.menuPosition || 'side';
500
+ const menuPosition = customMenuInfo.menuPosition || 'top';
486
501
  finalHtml = finalHtml.replace(
487
502
  /<body([^>]*)>/,
488
503
  `<body$1 data-custom-menu="${customMenuInfo.menuJsonPath}" data-menu-position="${menuPosition}">`
489
504
  );
505
+ } else {
506
+ // No custom menu — default to top menu
507
+ finalHtml = finalHtml.replace(
508
+ /<body([^>]*)>/,
509
+ `<body$1 data-menu-position="top">`
510
+ );
490
511
  }
512
+ } else {
513
+ // No custom menus at all — default to top menu
514
+ finalHtml = finalHtml.replace(
515
+ /<body([^>]*)>/,
516
+ `<body$1 data-menu-position="top">`
517
+ );
491
518
  }
492
519
 
493
520
  // Resolve relative URLs
@@ -568,7 +595,7 @@ async function buildBackgroundCaches() {
568
595
  const customMenuPath = join(output, menuInfo.menuJsonPath);
569
596
  const customMenuJson = JSON.stringify({
570
597
  menuData: menuInfo.menuData,
571
- menuPosition: menuInfo.menuPosition || 'side',
598
+ menuPosition: menuInfo.menuPosition || 'top',
572
599
  });
573
600
  await outputFile(customMenuPath, customMenuJson);
574
601
  }
@@ -615,7 +642,8 @@ async function buildBackgroundCaches() {
615
642
  const ext = extname(article);
616
643
  const base = basename(article, ext);
617
644
  const relativePath = article.replace(source, '').replace(/\.(md|txt|yml)$/, '.html');
618
- const title = toTitleCase(base);
645
+ const titleBase = (base === 'index' || base === 'home') ? basename(dirname(article)) : base;
646
+ const title = toTitleCase(titleBase || base);
619
647
 
620
648
  searchIndex.push({
621
649
  title,
@@ -732,13 +760,13 @@ export async function dev({
732
760
  if (url.startsWith('/public/custom-menu-') && url.endsWith('.json')) {
733
761
  // Find the menuDir from the cached customMenus by matching the JSON path
734
762
  let menuDir = null;
735
- let menuPosition = 'side';
763
+ let menuPosition = 'top';
736
764
 
737
765
  if (devState.customMenus) {
738
766
  for (const [dir, menuInfo] of devState.customMenus) {
739
767
  if (menuInfo.menuJsonPath === url) {
740
768
  menuDir = dir;
741
- menuPosition = menuInfo.menuPosition || 'side';
769
+ menuPosition = menuInfo.menuPosition || 'top';
742
770
  break;
743
771
  }
744
772
  }
@@ -750,7 +778,7 @@ export async function dev({
750
778
  if (menuInfo) {
751
779
  const { frontmatter, body } = extractMenuFrontmatter(menuInfo.content);
752
780
  const autoGenerate = frontmatter['auto-generate-menu'] === true || frontmatter['auto-generate-menu'] === 'true';
753
- const depth = parseInt(frontmatter['menu-depth'], 10) || 2;
781
+ const depth = parseInt(frontmatter['menu-depth'], 10) || 10;
754
782
  menuPosition = frontmatter['menu-position'] || menuPosition;
755
783
 
756
784
  let menuData;
@@ -109,6 +109,20 @@ function isIndexFile(baseName) {
109
109
  return baseName.toLowerCase() === 'index';
110
110
  }
111
111
 
112
+ /**
113
+ * Check if a file acts as the "index" for its parent folder.
114
+ * This is true for actual index files (index.md) and also for files
115
+ * whose name matches their parent folder (e.g. Arcanist/Arcanist.md).
116
+ * @param {string} filePath - Full path to the file
117
+ * @returns {boolean}
118
+ */
119
+ function isFolderNamedFile(filePath) {
120
+ const ext = extname(filePath);
121
+ const fileBase = basename(filePath, ext).toLowerCase();
122
+ const parentBase = basename(dirname(filePath)).toLowerCase();
123
+ return fileBase === parentBase;
124
+ }
125
+
112
126
  function hasIndexFile(dirPath) {
113
127
  for (const ext of INDEX_EXTENSIONS) {
114
128
  const indexPath = join(dirPath, `index${ext}`);
@@ -232,11 +246,29 @@ function resolveHref(rawHref, validPaths) {
232
246
  return { href: rawHref, inactive: true, debug: debugTries.join(' | ') };
233
247
  }
234
248
 
249
+ /**
250
+ * Recursively check if a directory-tree node contains any document files.
251
+ * @param {object} treeNode - A node from the directory-tree package
252
+ * @param {string[]} docExtensions - Extensions that count as documents
253
+ * @returns {boolean}
254
+ */
255
+ function treeHasDocuments(treeNode, docExtensions) {
256
+ if (!treeNode.children) {
257
+ // Leaf node (file) — check its extension
258
+ return docExtensions.includes(extname(treeNode.path));
259
+ }
260
+ // Directory — recurse into children
261
+ return treeNode.children.some(child => treeHasDocuments(child, docExtensions));
262
+ }
263
+
235
264
  // Build a flat tree structure with path info for JS navigation
236
265
  // Set includeDebug=false to exclude debug fields and reduce JSON size
237
266
  function buildMenuData(tree, source, validPaths, parentPath = '', includeDebug = true) {
238
267
  const items = [];
239
268
 
269
+ // Document extensions that count as "real content"
270
+ const DOC_EXTENSIONS = ['.md', '.mdx', '.txt', '.html'];
271
+
240
272
  // Files to hide from menu by default
241
273
  const hiddenFiles = ['config.json', 'style.css', 'footer.md'];
242
274
 
@@ -270,13 +302,18 @@ function buildMenuData(tree, source, validPaths, parentPath = '', includeDebug =
270
302
  continue; // Skip hidden folders
271
303
  }
272
304
 
305
+ // Skip folders that contain no document files (recursively)
306
+ if (hasChildren && !treeHasDocuments(item, DOC_EXTENSIONS)) {
307
+ continue;
308
+ }
309
+
273
310
  // Get folder config for custom label and icon (deprecated for labels, still used for icons/hidden)
274
311
  const folderConfig = hasChildren ? getFolderConfig(item.path) : null;
275
312
 
276
313
  // Determine the label - prefer menu-label from frontmatter
277
314
  let label;
278
315
  let sortKey;
279
- const isIndex = !hasChildren && isIndexFile(baseName);
316
+ const isIndex = !hasChildren && (isIndexFile(baseName) || isFolderNamedFile(item.path));
280
317
 
281
318
  if (hasChildren) {
282
319
  // For folders, get label from index.md frontmatter, then config.json, then folder name
@@ -286,7 +323,12 @@ function buildMenuData(tree, source, validPaths, parentPath = '', includeDebug =
286
323
  } else {
287
324
  // For files, check frontmatter for menu-label
288
325
  const fileLabel = getMenuLabelFromFile(item.path);
289
- label = fileLabel || toDisplayName(baseName);
326
+ if (isIndex) {
327
+ // Index files (index.md or foldername.md) default to "Home" label
328
+ label = fileLabel || 'Home';
329
+ } else {
330
+ label = fileLabel || toDisplayName(baseName);
331
+ }
290
332
  // Get sort key from file's frontmatter, fall back to baseName (not transformed label)
291
333
  // This ensures menu-sort-as values can match original filenames consistently
292
334
  const fileSortKey = getMenuSortAsFromFile(item.path);
@@ -347,7 +389,7 @@ function buildMenuData(tree, source, validPaths, parentPath = '', includeDebug =
347
389
  items.push(menuItem);
348
390
  }
349
391
 
350
- // Sort: folders first, then index files, then alphabetically by sortKey
392
+ // Sort: folders first (a-z), then index files, then other files (a-z)
351
393
  return items.sort((a, b) => {
352
394
  // Folders always come first
353
395
  if (a.hasChildren && !b.hasChildren) return -1;
@@ -355,31 +397,79 @@ function buildMenuData(tree, source, validPaths, parentPath = '', includeDebug =
355
397
  // Index files come before other files (after folders)
356
398
  if (a.isIndex && !b.isIndex) return -1;
357
399
  if (b.isIndex && !a.isIndex) return 1;
358
- // Alphabetical sort by sortKey (menu-sort-as or label)
359
- if (a.sortKey > b.sortKey) return 1;
360
- if (a.sortKey < b.sortKey) return -1;
400
+ // Alphabetical sort by sortKey (case-insensitive)
401
+ const aKey = (a.sortKey || '').toLowerCase();
402
+ const bKey = (b.sortKey || '').toLowerCase();
403
+ if (aKey > bKey) return 1;
404
+ if (aKey < bKey) return -1;
361
405
  return 0;
362
406
  });
363
407
  }
364
408
 
409
+ /**
410
+ * Post-process menu data to collapse single-document folders.
411
+ * When a folder contains only an index file (index.md) or a foldername-matching
412
+ * file (e.g. Arcanist/Arcanist.md) and no other children, the folder is replaced
413
+ * with a direct link to that document using the folder's label.
414
+ */
415
+ function collapseSingleDocFolders(items) {
416
+ return items.map(item => {
417
+ if (!item.hasChildren || !item.children) return item;
418
+
419
+ // Recurse first so nested single-doc folders are collapsed bottom-up
420
+ item.children = collapseSingleDocFolders(item.children);
421
+
422
+ // Check if the only child(ren) are index-like files (no sub-folders)
423
+ const nonIndexChildren = item.children.filter(c => !c.isIndex);
424
+ if (nonIndexChildren.length === 0 && item.children.length > 0) {
425
+ // All children are index files — collapse to a single link
426
+ const indexChild = item.children[0];
427
+ return {
428
+ ...item,
429
+ hasChildren: false,
430
+ children: undefined,
431
+ href: indexChild.href || item.href,
432
+ isIndex: false,
433
+ };
434
+ }
435
+
436
+ return item;
437
+ });
438
+ }
439
+
365
440
  export async function getAutomenu(source, validPaths) {
366
441
  const tree = dirTree(source, {
367
442
  exclude: /[\/\\]\.|node_modules/, // Exclude hidden folders (starting with .) and node_modules
368
443
  });
369
444
 
370
445
  // Build menu data WITHOUT debug fields for smaller JSON
371
- const menuData = buildMenuData(tree, source, validPaths, '', false);
446
+ let menuData = buildMenuData(tree, source, validPaths, '', false);
447
+
448
+ // Post-process: collapse single-document folders into direct links
449
+ menuData = collapseSingleDocFolders(menuData);
372
450
 
373
451
  // Get root config for openMenuItems setting
374
452
  const rootConfig = getRootConfig(source);
375
453
  const openMenuItems = rootConfig?.openMenuItems || [];
376
454
 
377
- // Add home item with resolved href
455
+ // Partition top-level items: files go under Home, folders stay at top level
456
+ const topLevelFolders = menuData.filter(item => item.hasChildren);
457
+ const topLevelFiles = menuData.filter(item => !item.hasChildren);
458
+
459
+ // Build Home item with top-level files as children
378
460
  const homeResolved = resolveHref('/', validPaths);
379
- const fullMenuData = [
380
- { label: 'Home', path: '', href: homeResolved.href, hasChildren: false, icon: `<span class="menu-icon">${HOME_ICON}</span>` },
381
- ...menuData
382
- ];
461
+ const homeItem = {
462
+ label: 'Home',
463
+ path: '',
464
+ href: homeResolved.href,
465
+ hasChildren: topLevelFiles.length > 0,
466
+ icon: `<span class="menu-icon">${HOME_ICON}</span>`,
467
+ };
468
+ if (topLevelFiles.length > 0) {
469
+ homeItem.children = topLevelFiles;
470
+ }
471
+
472
+ const fullMenuData = [homeItem, ...topLevelFolders];
383
473
 
384
474
  // Embed the openMenuItems config as JSON (small, safe to embed)
385
475
  const menuConfigScript = `<script type="application/json" id="menu-config">${JSON.stringify({ openMenuItems })}</script>`;