@kenjura/ursa 0.54.0 → 0.55.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.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,8 @@
1
+ # 0.55.0
2
+ 2025-12-21
3
+
4
+ - custom menus (menu.md|txt) override the automenu when present
5
+
1
6
  # 0.54.0
2
7
  2025-12-21
3
8
 
package/meta/default.css CHANGED
@@ -74,16 +74,46 @@ nav#nav-global {
74
74
  opacity: 0.7;
75
75
  }
76
76
 
77
- input#global-search {
77
+ .search-wrapper {
78
+ position: relative;
78
79
  width: var(--article-width);
79
80
  max-width: calc(100% - 16px);
81
+ justify-self: center;
82
+ }
83
+
84
+ input#global-search {
85
+ width: 100%;
80
86
  height: calc(var(--global-nav-height) - 16px);
81
87
  border-radius: 5px;
82
88
  background: rgba(0,0,0,0.25);
83
89
  color: var(--text-color);
84
90
  border-width: 0px;
85
- padding: 0 1rem;
86
- justify-self: center;
91
+ padding: 0 2.5rem 0 1rem;
92
+ box-sizing: border-box;
93
+ }
94
+
95
+ .search-clear-button {
96
+ position: absolute;
97
+ right: 8px;
98
+ top: 50%;
99
+ transform: translateY(-50%);
100
+ background: none;
101
+ border: none;
102
+ color: var(--text-color);
103
+ font-size: 1.25rem;
104
+ line-height: 1;
105
+ cursor: pointer;
106
+ padding: 4px 8px;
107
+ opacity: 0.6;
108
+ transition: opacity 0.2s ease;
109
+ }
110
+
111
+ .search-clear-button:hover {
112
+ opacity: 1;
113
+ }
114
+
115
+ .search-clear-button.hidden {
116
+ display: none;
87
117
  }
88
118
 
89
119
  .avatar {
@@ -152,10 +182,10 @@ nav#nav-global {
152
182
 
153
183
  nav#nav-main {
154
184
  position: fixed;
155
- top: calc(var(--global-nav-height) + 0.5rem);
185
+ top: calc(var(--global-nav-height));
156
186
  left: 0;
157
187
  width: 260px;
158
- max-height: calc(100vh - var(--global-nav-height) - 1rem);
188
+ max-height: calc(100vh - var(--global-nav-height));
159
189
  overflow-y: auto;
160
190
  padding: 0.5rem 0;
161
191
  font-size: 0.9rem;
@@ -379,12 +409,13 @@ nav#nav-main {
379
409
  /* Table of Contents - Right side navigation */
380
410
  nav#nav-toc {
381
411
  position: fixed;
382
- top: calc(var(--global-nav-height) + 1rem);
383
- right: 1rem;
412
+ top: calc(var(--global-nav-height));
413
+ right: 0;
384
414
  max-width: calc(50vw - var(--article-width) / 2 - 2rem);
385
- max-height: calc(100vh - var(--global-nav-height) - 2rem);
415
+ max-height: calc(100vh - var(--global-nav-height));
386
416
  overflow-y: auto;
387
417
  z-index: 100;
418
+ padding: 0.5rem 0;
388
419
 
389
420
  ul {
390
421
  list-style: none;
@@ -481,12 +512,16 @@ footer#site-footer {
481
512
  nav#nav-global {
482
513
  display: flex;
483
514
 
484
- input#global-search {
515
+ .search-wrapper {
485
516
  flex: 1;
486
517
  width: auto;
487
518
  max-width: none;
488
519
  margin: 0 8px;
489
520
  }
521
+
522
+ input#global-search {
523
+ width: 100%;
524
+ }
490
525
  }
491
526
  article#main-content {
492
527
  width: calc(100vw - 2rem);
package/meta/menu.js CHANGED
@@ -2,6 +2,10 @@ document.addEventListener('DOMContentLoaded', () => {
2
2
  const navMain = document.querySelector('nav#nav-main');
3
3
  if (!navMain) return;
4
4
 
5
+ // Check for custom menu
6
+ const customMenuPath = document.body.dataset.customMenu;
7
+ const isCustomMenu = !!customMenuPath;
8
+
5
9
  // State - menu data will be loaded asynchronously
6
10
  let menuData = null;
7
11
  let menuDataLoaded = false;
@@ -36,13 +40,16 @@ document.addEventListener('DOMContentLoaded', () => {
36
40
  /**
37
41
  * Load menu data from external JSON file
38
42
  * This is done asynchronously to avoid blocking page render
43
+ * Will load custom menu if data-custom-menu is present, otherwise loads auto-menu
39
44
  */
40
45
  async function loadMenuData() {
41
46
  if (menuDataLoaded || menuDataLoading) return;
42
47
  menuDataLoading = true;
43
48
 
44
49
  try {
45
- const response = await fetch('/public/menu-data.json');
50
+ // Use custom menu path if present, otherwise use auto-menu
51
+ const menuUrl = customMenuPath || '/public/menu-data.json';
52
+ const response = await fetch(menuUrl);
46
53
  if (!response.ok) {
47
54
  throw new Error(`HTTP ${response.status}`);
48
55
  }
@@ -50,7 +57,11 @@ document.addEventListener('DOMContentLoaded', () => {
50
57
  menuDataLoaded = true;
51
58
 
52
59
  // Re-render menu now that we have full data
53
- initializeFromCurrentPage();
60
+ if (isCustomMenu) {
61
+ renderCustomMenu();
62
+ } else {
63
+ initializeFromCurrentPage();
64
+ }
54
65
  } catch (error) {
55
66
  console.error('Failed to load menu data:', error);
56
67
  menuDataLoaded = true; // Mark as loaded to prevent retries
@@ -231,6 +242,230 @@ document.addEventListener('DOMContentLoaded', () => {
231
242
  attachClickHandlers();
232
243
  }
233
244
 
245
+ /**
246
+ * Get the custom menu root folder name and parent URL from the current location
247
+ * @returns {{name: string, parentUrl: string}} - Menu root name and parent URL
248
+ */
249
+ function getCustomMenuInfo() {
250
+ const pathname = window.location.pathname;
251
+ // Remove the filename to get the directory
252
+ const pathParts = pathname.split('/').filter(Boolean);
253
+
254
+ // Find the menu root by looking at the custom menu path
255
+ // e.g., /public/custom-menu-systems-system6.json -> systems/system6
256
+ if (customMenuPath) {
257
+ const match = customMenuPath.match(/custom-menu-(.+)\.json$/);
258
+ if (match) {
259
+ const menuId = match[1];
260
+ // Convert dashes back to path (e.g., systems-system6 -> systems/system6)
261
+ // But we need to be careful - the ID uses dashes for path separators
262
+ // The actual menu root is the folder containing the menu file
263
+ // We can infer it from the current URL path
264
+
265
+ // Find common prefix between current path and menu ID
266
+ const menuParts = menuId.split('-');
267
+ let matchedParts = [];
268
+ let currentIndex = 0;
269
+
270
+ for (let i = 0; i < pathParts.length && currentIndex < menuParts.length; i++) {
271
+ // Check if this path part matches the next menu ID parts
272
+ let pathPart = pathParts[i].toLowerCase();
273
+ let menuPart = menuParts[currentIndex].toLowerCase();
274
+
275
+ // Handle multi-word folder names (e.g., "Galactic Horizons" -> "GalacticHorizons")
276
+ if (pathPart.replace(/\s+/g, '').toLowerCase() === menuPart.toLowerCase()) {
277
+ matchedParts.push(pathParts[i]);
278
+ currentIndex++;
279
+ } else if (pathPart === menuPart) {
280
+ matchedParts.push(pathParts[i]);
281
+ currentIndex++;
282
+ }
283
+ }
284
+
285
+ if (matchedParts.length > 0) {
286
+ const menuRootName = matchedParts[matchedParts.length - 1];
287
+ // Parent URL is one level up from the menu root
288
+ const parentParts = matchedParts.slice(0, -1);
289
+ const parentUrl = parentParts.length > 0
290
+ ? '/' + parentParts.join('/') + '/index.html'
291
+ : '/index.html';
292
+
293
+ return { name: menuRootName, parentUrl };
294
+ }
295
+ }
296
+ }
297
+
298
+ // Fallback: use the first directory in the current path
299
+ if (pathParts.length > 0) {
300
+ return {
301
+ name: pathParts[0],
302
+ parentUrl: '/index.html'
303
+ };
304
+ }
305
+
306
+ return { name: 'Menu', parentUrl: '/index.html' };
307
+ }
308
+
309
+ /**
310
+ * Render custom menu with two-level display
311
+ * Custom menus use a simpler structure but same visual style
312
+ */
313
+ function renderCustomMenu() {
314
+ // Wait for menu data to load
315
+ if (!menuData) {
316
+ menuContainer.innerHTML = '<li class="menu-loading">Loading menu...</li>';
317
+ return;
318
+ }
319
+
320
+ // Show breadcrumb for custom menus with menu root name and back button
321
+ if (breadcrumb) {
322
+ const menuInfo = getCustomMenuInfo();
323
+ breadcrumb.style.display = 'flex';
324
+ if (currentPathSpan) {
325
+ currentPathSpan.textContent = menuInfo.name;
326
+ }
327
+ // Update back button to navigate to parent
328
+ if (backButton) {
329
+ backButton.onclick = (e) => {
330
+ e.preventDefault();
331
+ window.location.href = menuInfo.parentUrl;
332
+ };
333
+ }
334
+ // Hide home button for custom menus (back is enough)
335
+ if (homeButton) {
336
+ homeButton.style.display = 'none';
337
+ }
338
+ }
339
+
340
+ // Build HTML for level 1 and level 2 (max 2 levels shown)
341
+ let html = '';
342
+ for (const item of menuData) {
343
+ const isActive = isCurrentPage(item);
344
+ const activeClass = isActive ? ' current-menu-item' : '';
345
+ const hasChildrenClass = item.hasChildren ? ' has-children' : '';
346
+
347
+ // Auto-expand items that contain the current page
348
+ const containsCurrentPage = item.hasChildren && isCurrentPageInSubtree(item);
349
+ const isExpanded = expandedLevel1.has(item.path) || (containsCurrentPage && !collapsedLevel1.has(item.path));
350
+ const expandedClass = isExpanded ? ' expanded' : '';
351
+ const caretIndicator = item.hasChildren
352
+ ? `<span class="menu-caret">${isExpanded ? '▼' : '▶'}</span>`
353
+ : '';
354
+
355
+ const labelHtml = item.href
356
+ ? `<a href="${item.href}" class="menu-label">${item.label}</a>`
357
+ : `<span class="menu-label">${item.label}</span>`;
358
+
359
+ html += `
360
+ <li class="menu-item level-1${hasChildrenClass}${activeClass}${expandedClass}" data-path="${item.path}">
361
+ <div class="menu-item-row">
362
+ ${item.icon || '<span class="menu-icon">📄</span>'}
363
+ ${labelHtml}
364
+ ${caretIndicator}
365
+ </div>`;
366
+
367
+ // Show children if expanded
368
+ if (item.children && item.children.length > 0 && isExpanded) {
369
+ html += '<ul class="menu-sublevel">';
370
+ for (const child of item.children) {
371
+ const childActive = isCurrentPage(child);
372
+ const childActiveClass = childActive ? ' current-menu-item' : '';
373
+ const childHasChildren = child.hasChildren ? ' has-children' : '';
374
+ // Level-2 items with children get triple-dot indicator
375
+ const childMoreIndicator = child.hasChildren ? '<span class="menu-more" title="Has sub-items">⋮</span>' : '';
376
+
377
+ const childLabelHtml = child.href
378
+ ? `<a href="${child.href}" class="menu-label">${child.label}</a>`
379
+ : `<span class="menu-label">${child.label}</span>`;
380
+
381
+ html += `
382
+ <li class="menu-item level-2${childHasChildren}${childActiveClass}" data-path="${child.path}">
383
+ <div class="menu-item-row">
384
+ ${child.icon || '<span class="menu-icon">📄</span>'}
385
+ ${childLabelHtml}
386
+ ${childMoreIndicator}
387
+ </div>
388
+ </li>`;
389
+ }
390
+ html += '</ul>';
391
+ }
392
+
393
+ html += '</li>';
394
+ }
395
+
396
+ menuContainer.innerHTML = html;
397
+
398
+ // Attach click handlers for custom menu
399
+ attachCustomMenuClickHandlers();
400
+ }
401
+
402
+ /**
403
+ * Check if current page is within an item's subtree (for custom menus)
404
+ */
405
+ function isCurrentPageInSubtree(item) {
406
+ if (isCurrentPage(item)) return true;
407
+ if (item.children) {
408
+ for (const child of item.children) {
409
+ if (isCurrentPageInSubtree(child)) return true;
410
+ }
411
+ }
412
+ return false;
413
+ }
414
+
415
+ /**
416
+ * Attach click handlers for custom menu items
417
+ */
418
+ function attachCustomMenuClickHandlers() {
419
+ const menuItems = menuContainer.querySelectorAll('.menu-item.level-1.has-children');
420
+ menuItems.forEach(li => {
421
+ const row = li.querySelector('.menu-item-row');
422
+ const caret = li.querySelector('.menu-caret');
423
+ const link = li.querySelector('a.menu-label');
424
+ const path = li.dataset.path;
425
+
426
+ const toggleExpand = (e) => {
427
+ e.preventDefault();
428
+ e.stopPropagation();
429
+
430
+ const isCurrentlyExpanded = li.classList.contains('expanded');
431
+
432
+ if (isCurrentlyExpanded) {
433
+ expandedLevel1.delete(path);
434
+ collapsedLevel1.add(path);
435
+ } else {
436
+ expandedLevel1.add(path);
437
+ collapsedLevel1.delete(path);
438
+ }
439
+ renderCustomMenu();
440
+ };
441
+
442
+ if (caret) {
443
+ caret.addEventListener('click', toggleExpand);
444
+ }
445
+
446
+ // If clicking the link on current page, toggle instead of navigate
447
+ if (link) {
448
+ link.addEventListener('click', (e) => {
449
+ const linkHref = link.getAttribute('href');
450
+ const currentHref = window.location.pathname;
451
+ const normalizedLinkHref = linkHref.replace(/\/index\.html$/, '').replace(/\.html$/, '');
452
+ const normalizedCurrentHref = currentHref.replace(/\/index\.html$/, '').replace(/\.html$/, '');
453
+
454
+ if (normalizedLinkHref === normalizedCurrentHref) {
455
+ toggleExpand(e);
456
+ }
457
+ });
458
+ }
459
+
460
+ // Clicking row (not link) toggles
461
+ row.addEventListener('click', (e) => {
462
+ if (!e.target.closest('a')) {
463
+ toggleExpand(e);
464
+ }
465
+ });
466
+ });
467
+ }
468
+
234
469
  // Check if an item matches the current page
235
470
  function isCurrentPage(item) {
236
471
  if (!item.href) return false;
package/meta/search.js CHANGED
@@ -18,12 +18,31 @@ class GlobalSearch {
18
18
  }
19
19
 
20
20
  init() {
21
+ this.wrapSearchInput();
21
22
  this.createResultsContainer();
23
+ this.createClearButton();
22
24
  this.bindEvents();
23
25
  // Start loading the index immediately (but don't block)
24
26
  this.loadSearchIndex();
25
27
  }
26
28
 
29
+ wrapSearchInput() {
30
+ // Wrap the search input in a container for positioning the clear button
31
+ this.searchWrapper = document.createElement('div');
32
+ this.searchWrapper.className = 'search-wrapper';
33
+ this.searchInput.parentNode.insertBefore(this.searchWrapper, this.searchInput);
34
+ this.searchWrapper.appendChild(this.searchInput);
35
+ }
36
+
37
+ createClearButton() {
38
+ this.clearButton = document.createElement('button');
39
+ this.clearButton.className = 'search-clear-button hidden';
40
+ this.clearButton.type = 'button';
41
+ this.clearButton.setAttribute('aria-label', 'Clear search');
42
+ this.clearButton.innerHTML = '×';
43
+ this.searchWrapper.appendChild(this.clearButton);
44
+ }
45
+
27
46
  createResultsContainer() {
28
47
  this.searchResults = document.createElement('div');
29
48
  this.searchResults.id = 'search-results';
@@ -72,6 +91,13 @@ class GlobalSearch {
72
91
  // Input events
73
92
  this.searchInput.addEventListener('input', (e) => {
74
93
  this.handleSearch(e.target.value);
94
+ this.updateClearButtonVisibility();
95
+ });
96
+
97
+ // Clear button click
98
+ this.clearButton.addEventListener('click', (e) => {
99
+ e.preventDefault();
100
+ this.clearSearch();
75
101
  });
76
102
 
77
103
  // Keyboard navigation
@@ -247,6 +273,21 @@ class GlobalSearch {
247
273
  this.currentSelection = -1;
248
274
  }
249
275
 
276
+ clearSearch() {
277
+ this.searchInput.value = '';
278
+ this.hideResults();
279
+ this.updateClearButtonVisibility();
280
+ this.searchInput.focus();
281
+ }
282
+
283
+ updateClearButtonVisibility() {
284
+ if (this.searchInput.value.trim()) {
285
+ this.clearButton.classList.remove('hidden');
286
+ } else {
287
+ this.clearButton.classList.add('hidden');
288
+ }
289
+ }
290
+
250
291
  handleKeydown(e) {
251
292
  const items = this.searchResults.querySelectorAll('.search-result-item');
252
293
 
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.54.0",
5
+ "version": "0.55.0",
6
6
  "description": "static site generator from MD/wikitext/YML",
7
7
  "main": "lib/index.js",
8
8
  "bin": {
@@ -1,6 +1,8 @@
1
1
  // Menu helpers for build
2
2
  import { getAutomenu } from "../automenu.js";
3
3
  import { renderFile } from "../fileRenderer.js";
4
+ import { findCustomMenu, parseCustomMenu, buildCustomMenuHtml } from "../customMenu.js";
5
+ import { dirname, relative, resolve } from "path";
4
6
 
5
7
  /**
6
8
  * Get menu HTML and menu data from source directory
@@ -17,3 +19,85 @@ export async function getMenu(allSourceFilenames, source, validPaths) {
17
19
  menuData: menuResult.menuData
18
20
  };
19
21
  }
22
+
23
+ /**
24
+ * Find all unique custom menus in the source tree
25
+ * @param {string[]} allSourceFilenames - All source file names
26
+ * @param {string} source - Source directory path
27
+ * @returns {Map<string, {menuPath: string, menuDir: string, menuData: Array}>} Map of menu dir to menu info
28
+ */
29
+ export function findAllCustomMenus(allSourceFilenames, source) {
30
+ const customMenus = new Map();
31
+ const checkedDirs = new Set();
32
+
33
+ // Check each directory for custom menus
34
+ for (const file of allSourceFilenames) {
35
+ const dir = dirname(file);
36
+ if (checkedDirs.has(dir)) continue;
37
+ checkedDirs.add(dir);
38
+
39
+ const menuInfo = findCustomMenu(dir, source);
40
+ if (menuInfo && !customMenus.has(menuInfo.menuDir)) {
41
+ const menuData = parseCustomMenu(menuInfo.content, menuInfo.menuDir, source);
42
+ customMenus.set(menuInfo.menuDir, {
43
+ menuPath: menuInfo.path,
44
+ menuDir: menuInfo.menuDir,
45
+ menuData,
46
+ // The URL path for the menu JSON file
47
+ menuJsonPath: '/public/custom-menu-' + getMenuId(menuInfo.menuDir, source) + '.json',
48
+ });
49
+ }
50
+ }
51
+
52
+ return customMenus;
53
+ }
54
+
55
+ /**
56
+ * Generate a unique ID for a custom menu based on its directory
57
+ * @param {string} menuDir - The directory where the menu is located
58
+ * @param {string} source - The source root directory
59
+ * @returns {string} - A URL-safe ID
60
+ */
61
+ function getMenuId(menuDir, source) {
62
+ const relativePath = relative(source, menuDir);
63
+ if (!relativePath) return 'root';
64
+ return relativePath.replace(/[\/\\]/g, '-').replace(/[^a-zA-Z0-9-]/g, '');
65
+ }
66
+
67
+ /**
68
+ * Get the custom menu info for a specific file path
69
+ * @param {string} filePath - The source file path
70
+ * @param {string} source - The source root directory
71
+ * @param {Map} customMenus - Map of all custom menus
72
+ * @returns {{menuJsonPath: string, menuDir: string} | null} - Custom menu info or null
73
+ */
74
+ export function getCustomMenuForFile(filePath, source, customMenus) {
75
+ const fileDir = resolve(dirname(filePath));
76
+ const sourceResolved = resolve(source);
77
+
78
+ // Walk up from file dir to find matching custom menu
79
+ let currentDir = fileDir;
80
+ while (currentDir.startsWith(sourceResolved)) {
81
+ if (customMenus.has(currentDir)) {
82
+ const menuInfo = customMenus.get(currentDir);
83
+ return {
84
+ menuJsonPath: menuInfo.menuJsonPath,
85
+ menuDir: relative(source, menuInfo.menuDir) || '',
86
+ };
87
+ }
88
+ const parentDir = dirname(currentDir);
89
+ if (parentDir === currentDir) break;
90
+ currentDir = parentDir;
91
+ }
92
+
93
+ return null;
94
+ }
95
+
96
+ /**
97
+ * Build HTML for a custom menu
98
+ * @param {Array} menuData - The parsed menu data
99
+ * @returns {string} - HTML string for the menu
100
+ */
101
+ export function buildCustomMenuHtmlExport(menuData) {
102
+ return buildCustomMenuHtml(menuData);
103
+ }
@@ -0,0 +1,303 @@
1
+ // Custom menu support - allows defining custom menus in menu.md, menu.txt, _menu.md, or _menu.txt
2
+ import { existsSync, readFileSync } from "fs";
3
+ import { join, dirname, relative, resolve, basename } from "path";
4
+
5
+ // Menu file names to look for (in order of priority)
6
+ const MENU_FILE_NAMES = ['menu.md', 'menu.txt', '_menu.md', '_menu.txt'];
7
+
8
+ // Source file extensions to check
9
+ const SOURCE_EXTENSIONS = ['.md', '.txt'];
10
+
11
+ // Default icons
12
+ const FOLDER_ICON = '📁';
13
+ const DOCUMENT_ICON = '📄';
14
+
15
+ /**
16
+ * Check if a source file exists for a given path
17
+ * Checks for: ./Foo.md, ./Foo.txt, ./Foo/index.md, ./Foo/index.txt, ./Foo/home.md, ./Foo/home.txt, ./Foo/Foo.md, ./Foo/Foo.txt
18
+ * @param {string} basePath - The base path without extension (absolute path in source)
19
+ * @returns {boolean} - True if a source file exists
20
+ */
21
+ function sourceFileExists(basePath) {
22
+ const name = basename(basePath);
23
+
24
+ // Check direct file (./Foo.md, ./Foo.txt)
25
+ for (const ext of SOURCE_EXTENSIONS) {
26
+ if (existsSync(basePath + ext)) {
27
+ return true;
28
+ }
29
+ }
30
+
31
+ // Check as folder with index files (./Foo/index.md, ./Foo/index.txt, ./Foo/home.md, ./Foo/home.txt, ./Foo/Foo.md, ./Foo/Foo.txt)
32
+ const indexNames = ['index', 'home', name];
33
+ for (const indexName of indexNames) {
34
+ for (const ext of SOURCE_EXTENSIONS) {
35
+ if (existsSync(join(basePath, indexName + ext))) {
36
+ return true;
37
+ }
38
+ }
39
+ }
40
+
41
+ return false;
42
+ }
43
+
44
+ /**
45
+ * Find a custom menu file in the given directory or any parent directory
46
+ * @param {string} dirPath - The directory to start searching from
47
+ * @param {string} sourceRoot - The root source directory (stop searching here)
48
+ * @returns {{path: string, content: string, menuDir: string} | null} - Menu file info or null if not found
49
+ */
50
+ export function findCustomMenu(dirPath, sourceRoot) {
51
+ // Normalize paths
52
+ const normalizedDir = resolve(dirPath);
53
+ const normalizedRoot = resolve(sourceRoot);
54
+
55
+ let currentDir = normalizedDir;
56
+
57
+ // Walk up the directory tree until we reach or pass the source root
58
+ while (currentDir.startsWith(normalizedRoot)) {
59
+ for (const menuFileName of MENU_FILE_NAMES) {
60
+ const menuPath = join(currentDir, menuFileName);
61
+ if (existsSync(menuPath)) {
62
+ try {
63
+ const content = readFileSync(menuPath, 'utf8');
64
+ return {
65
+ path: menuPath,
66
+ content,
67
+ menuDir: currentDir, // The directory where the menu was found
68
+ };
69
+ } catch (e) {
70
+ console.error(`Error reading menu file ${menuPath}:`, e);
71
+ }
72
+ }
73
+ }
74
+
75
+ // Move up one directory
76
+ const parentDir = dirname(currentDir);
77
+ if (parentDir === currentDir) {
78
+ // Reached filesystem root
79
+ break;
80
+ }
81
+ currentDir = parentDir;
82
+ }
83
+
84
+ return null;
85
+ }
86
+
87
+ /**
88
+ * Parse a custom menu file and return menu data structure
89
+ * Supports two formats:
90
+ *
91
+ * Markdown format:
92
+ * - [Label](./relative/path)
93
+ * - [Child Label](./relative/child/path)
94
+ *
95
+ * Wikitext format:
96
+ * * [[path|Label]]
97
+ * ** [[child/path|Child Label]]
98
+ * or
99
+ * * [[Label]] (path derived from label)
100
+ *
101
+ * @param {string} content - The menu file content
102
+ * @param {string} menuDir - The directory where the menu file was found
103
+ * @param {string} sourceRoot - The root source directory
104
+ * @returns {Array} - Menu data array compatible with the existing menu system
105
+ */
106
+ export function parseCustomMenu(content, menuDir, sourceRoot) {
107
+ const lines = content.split('\n');
108
+ const menuItems = [];
109
+ const stack = [{ children: menuItems, indent: -1 }]; // Stack for tracking nesting
110
+
111
+ for (const line of lines) {
112
+ // Skip empty lines
113
+ const trimmedLine = line.trim();
114
+ if (!trimmedLine) {
115
+ continue;
116
+ }
117
+
118
+ let label = null;
119
+ let href = null;
120
+ let indent = 0;
121
+
122
+ // Try wikitext format first: * [[path|Label]] or * [[Label]]
123
+ if (trimmedLine.match(/^\*+\s*\[\[/)) {
124
+ // Count asterisks for indent level
125
+ const asteriskMatch = trimmedLine.match(/^(\*+)/);
126
+ indent = asteriskMatch ? (asteriskMatch[1].length - 1) * 2 : 0; // Convert to space-equivalent
127
+
128
+ // Parse wikitext link: [[path|label]] or [[label]]
129
+ const wikiMatch = trimmedLine.match(/\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/);
130
+ if (wikiMatch) {
131
+ if (wikiMatch[2]) {
132
+ // [[path|label]] format - first part is path, second is label
133
+ // Special case: _home means index
134
+ const pathPart = wikiMatch[1] === '_home' ? 'index' : wikiMatch[1];
135
+ label = wikiMatch[2];
136
+ href = './' + pathPart;
137
+ } else {
138
+ // [[label]] format - path derived from label
139
+ label = wikiMatch[1];
140
+ // Special case: _home means index
141
+ href = './' + (wikiMatch[1] === '_home' ? 'index' : wikiMatch[1]);
142
+ }
143
+ }
144
+ }
145
+ // Try markdown format: - [Label](path)
146
+ else if (trimmedLine.startsWith('-')) {
147
+ // Calculate indentation level (count leading spaces/tabs before the dash)
148
+ const leadingWhitespace = line.match(/^(\s*)/)[1];
149
+ indent = leadingWhitespace.length;
150
+
151
+ // Parse the markdown link: - [Label](path)
152
+ const linkMatch = trimmedLine.match(/^-\s*\[([^\]]+)\]\(([^)]+)\)/);
153
+ if (linkMatch) {
154
+ label = linkMatch[1];
155
+ href = linkMatch[2];
156
+ }
157
+ }
158
+
159
+ // Skip if we couldn't parse
160
+ if (!label || !href) {
161
+ continue;
162
+ }
163
+
164
+ // Resolve relative paths based on where the menu file was found
165
+ // Resolve relative paths and check if source file exists
166
+ let absoluteSourcePath = null;
167
+ if (href.startsWith('./') || href.startsWith('../') || !href.startsWith('/')) {
168
+ // It's a relative path - resolve it relative to the menu directory
169
+ absoluteSourcePath = resolve(menuDir, href);
170
+
171
+ // Check if the source file exists
172
+ const fileExists = sourceFileExists(absoluteSourcePath);
173
+
174
+ if (fileExists) {
175
+ // Convert to web-accessible path (relative to source root)
176
+ href = '/' + relative(sourceRoot, absoluteSourcePath);
177
+ // Normalize path separators for web
178
+ href = href.replace(/\\/g, '/');
179
+ // Ensure it ends with .html if it doesn't have an extension
180
+ if (!href.match(/\.[a-z]+$/i)) {
181
+ // Check if it's likely a folder (ends with /) or file
182
+ if (href.endsWith('/')) {
183
+ href = href + 'index.html';
184
+ } else {
185
+ // Assume it's a file - add .html
186
+ href = href + '.html';
187
+ }
188
+ }
189
+ } else {
190
+ // Source file doesn't exist - this is a non-navigable menu item
191
+ href = null;
192
+ }
193
+ }
194
+
195
+ const menuItem = {
196
+ label,
197
+ path: label.toLowerCase().replace(/\s+/g, '-'), // Generate path from label
198
+ href,
199
+ hasChildren: false, // Will be updated if children are added
200
+ icon: `<span class="menu-icon">${href ? DOCUMENT_ICON : FOLDER_ICON}</span>`,
201
+ children: [],
202
+ };
203
+
204
+ // Find the correct parent based on indentation
205
+ while (stack.length > 1 && stack[stack.length - 1].indent >= indent) {
206
+ stack.pop();
207
+ }
208
+
209
+ // Add this item to the current parent
210
+ const parent = stack[stack.length - 1];
211
+ parent.children.push(menuItem);
212
+
213
+ // If this is the first child, mark parent as having children
214
+ if (parent.menuItem) {
215
+ parent.menuItem.hasChildren = true;
216
+ parent.menuItem.icon = `<span class="menu-icon">${FOLDER_ICON}</span>`;
217
+ }
218
+
219
+ // Push this item onto the stack as a potential parent
220
+ stack.push({ children: menuItem.children, indent, menuItem });
221
+ }
222
+
223
+ return menuItems;
224
+ }
225
+
226
+ /**
227
+ * Get custom menu data for a given file path
228
+ * @param {string} filePath - The source file path
229
+ * @param {string} sourceRoot - The root source directory
230
+ * @returns {{menuData: Array, menuPath: string} | null} - Menu data and path, or null if no custom menu
231
+ */
232
+ export function getCustomMenuForFile(filePath, sourceRoot) {
233
+ const fileDir = dirname(filePath);
234
+ const customMenuInfo = findCustomMenu(fileDir, sourceRoot);
235
+
236
+ if (!customMenuInfo) {
237
+ return null;
238
+ }
239
+
240
+ const menuData = parseCustomMenu(customMenuInfo.content, customMenuInfo.menuDir, sourceRoot);
241
+
242
+ return {
243
+ menuData,
244
+ menuPath: customMenuInfo.path,
245
+ menuDir: customMenuInfo.menuDir,
246
+ };
247
+ }
248
+
249
+ /**
250
+ * Build menu HTML structure from custom menu data
251
+ * This matches the format expected by the existing menu.js client-side code
252
+ * @param {Array} menuData - The parsed menu data
253
+ * @returns {string} - HTML string for the menu
254
+ */
255
+ export function buildCustomMenuHtml(menuData) {
256
+ const menuConfigScript = `<script type="application/json" id="menu-config">${JSON.stringify({ openMenuItems: [], customMenu: true })}</script>`;
257
+
258
+ const breadcrumbHtml = `
259
+ <div class="menu-breadcrumb" style="display: none;">
260
+ <button class="menu-back" title="Go back">←</button>
261
+ <button class="menu-home" title="Go to root">🏠</button>
262
+ <span class="menu-current-path"></span>
263
+ </div>`;
264
+
265
+ const menuHtml = renderCustomMenuLevel(menuData);
266
+
267
+ return `${menuConfigScript}${breadcrumbHtml}<ul class="menu-level" data-level="0">${menuHtml}</ul>`;
268
+ }
269
+
270
+ /**
271
+ * Render a level of the custom menu
272
+ * @param {Array} items - Menu items at this level
273
+ * @returns {string} - HTML string
274
+ */
275
+ function renderCustomMenuLevel(items) {
276
+ return items.map(item => {
277
+ const hasChildrenClass = item.hasChildren ? ' has-children' : '';
278
+ const hasChildrenIndicator = item.hasChildren ? '<span class="menu-more">⋯</span>' : '';
279
+
280
+ const labelHtml = item.href
281
+ ? `<a href="${item.href}" class="menu-label">${item.label}</a>`
282
+ : `<span class="menu-label">${item.label}</span>`;
283
+
284
+ return `
285
+ <li class="menu-item${hasChildrenClass}" data-path="${item.path}">
286
+ <div class="menu-item-row">
287
+ ${item.icon}
288
+ ${labelHtml}
289
+ ${hasChildrenIndicator}
290
+ </div>
291
+ </li>`;
292
+ }).join('');
293
+ }
294
+
295
+ /**
296
+ * Check if a directory (or any parent) has a custom menu
297
+ * @param {string} dirPath - The directory to check
298
+ * @param {string} sourceRoot - The root source directory
299
+ * @returns {boolean} - True if a custom menu exists
300
+ */
301
+ export function hasCustomMenu(dirPath, sourceRoot) {
302
+ return findCustomMenu(dirPath, sourceRoot) !== null;
303
+ }
@@ -46,6 +46,8 @@ import {
46
46
  addTrailingSlash,
47
47
  getTemplates,
48
48
  getMenu,
49
+ findAllCustomMenus,
50
+ getCustomMenuForFile,
49
51
  getTransformedMetadata,
50
52
  getFooter,
51
53
  generateAutoIndices,
@@ -161,6 +163,10 @@ export async function generate({
161
163
  const menu = menuResult.html;
162
164
  const menuData = menuResult.menuData;
163
165
 
166
+ // Find all custom menus in the source tree
167
+ const customMenus = findAllCustomMenus(allSourceFilenames, source);
168
+ progress.log(`Found ${customMenus.size} custom menu(s)`);
169
+
164
170
  // Get and increment build ID from .ursa.json
165
171
  const buildId = getAndIncrementBuildId(resolve(_source));
166
172
  progress.log(`Build #${buildId}`);
@@ -334,6 +340,9 @@ export async function generate({
334
340
  throw new Error(`Template not found. Requested: "${requestedTemplateName || DEFAULT_TEMPLATE_NAME}". Available templates: ${Object.keys(templates).join(', ') || 'none'}`);
335
341
  }
336
342
 
343
+ // Check if this file has a custom menu
344
+ const customMenuInfo = getCustomMenuForFile(file, source, customMenus);
345
+
337
346
  // Build final HTML with all replacements in a single regex pass
338
347
  // This avoids creating 8 intermediate strings
339
348
  const replacements = {
@@ -350,6 +359,14 @@ export async function generate({
350
359
  const pattern = /\$\{(title|menu|meta|transformedMetadata|body|styleLink|searchIndex|footer)\}/g;
351
360
  let finalHtml = template.replace(pattern, (match) => replacements[match] ?? match);
352
361
 
362
+ // If this page has a custom menu, add data attribute to body
363
+ if (customMenuInfo) {
364
+ finalHtml = finalHtml.replace(
365
+ /<body([^>]*)>/,
366
+ `<body$1 data-custom-menu="${customMenuInfo.menuJsonPath}">`
367
+ );
368
+ }
369
+
353
370
  // Resolve links and mark broken internal links as inactive
354
371
  finalHtml = markInactiveLinks(finalHtml, validPaths, docUrlPath, false);
355
372
 
@@ -414,6 +431,14 @@ export async function generate({
414
431
  progress.log(`Writing menu data (${(menuDataJson.length / 1024).toFixed(1)} KB)`);
415
432
  await outputFile(menuDataPath, menuDataJson);
416
433
 
434
+ // Write custom menu JSON files
435
+ for (const [menuDir, menuInfo] of customMenus) {
436
+ const customMenuPath = join(output, menuInfo.menuJsonPath);
437
+ const customMenuJson = JSON.stringify(menuInfo.menuData);
438
+ progress.log(`Writing custom menu: ${menuInfo.menuJsonPath}`);
439
+ await outputFile(customMenuPath, customMenuJson);
440
+ }
441
+
417
442
  // Process directory indices with batched concurrency
418
443
  const totalDirs = allSourceFilenamesThatAreDirectories.length;
419
444
  let processedDirs = 0;