@kenjura/ursa 0.77.0 → 0.79.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,17 @@
1
+ # 0.79.0
2
+ 2026-02-14
3
+
4
+ - Menu fixes:
5
+ - Zero-item submenus will no longer be hidden if they have subfolders with one+ item
6
+ - Bug fix for missing menu items
7
+ - Level 2+ menus now have overflow:scroll (vertical only)
8
+
9
+ # 0.78.0
10
+ 2026-02-13
11
+
12
+ - added release-it
13
+ - --clean now fully deletes the .ursa cache folder and clears the output directory before generation, ensuring a completely fresh build without any stale files. Previously, --clean only ignored the cache but left existing output files in place, which could cause issues with stale auto-generated indexes and other files blocking new generation. This change provides a more robust clean build experience.
14
+
1
15
  # 0.77.0
2
16
  2026-02-13
3
17
 
package/README.md CHANGED
@@ -66,7 +66,7 @@ Start a development server that:
66
66
  - `--port, -p` - Port for development server (default: 8080, serve command only)
67
67
  - `--whitelist, -w` - Path to whitelist file containing patterns for files to include
68
68
  - `--exclude, -e` - Folders to exclude: comma-separated paths relative to source, or path to file with one folder per line
69
- - `--clean` - Clear output directory and ignore cache, forcing full regeneration
69
+ - `--clean` - Delete the `.ursa` cache folder and clear output directory, forcing full regeneration
70
70
 
71
71
  ### Whitelist File Format
72
72
 
package/meta/default.css CHANGED
@@ -735,7 +735,7 @@ nav#nav-main-top .dropdown-label {
735
735
  }
736
736
 
737
737
  nav#nav-main-top .dropdown-label:hover {
738
- background-color: var(--nav-top-bg);
738
+ background-color: rgba(255, 255, 255, 0.1);
739
739
  }
740
740
 
741
741
  nav#nav-main-top a.dropdown-label:hover {
@@ -749,14 +749,12 @@ nav#nav-main-top .flyout-indicator {
749
749
  margin-left: 8px;
750
750
  }
751
751
 
752
- /* Flyout menu (nested children) */
752
+ /* Flyout menu (nested children) - uses fixed positioning to avoid clipping by parent overflow */
753
753
  nav#nav-main-top .top-menu-flyout {
754
754
  display: none;
755
- position: absolute;
756
- left: 100%;
757
- top: -1px;
755
+ position: fixed;
758
756
  min-width: 200px;
759
- max-height: calc(100vh - var(--global-nav-height) - 20px);
757
+ max-height: calc(100vh - var(--global-nav-height) - 40px);
760
758
  overflow-y: auto;
761
759
  background-color: var(--widget-bg);
762
760
  border: 1px solid var(--widget-border);
@@ -766,9 +764,10 @@ nav#nav-main-top .top-menu-flyout {
766
764
  margin: 0;
767
765
  padding: 0;
768
766
  z-index: 1006;
767
+ /* JS will set top/left via inline styles */
769
768
  }
770
769
 
771
- /* Show flyout on hover */
770
+ /* Show flyout on hover - JS handles positioning */
772
771
  nav#nav-main-top .dropdown-item.has-flyout:hover > .top-menu-flyout {
773
772
  display: block;
774
773
  }
package/meta/menu.js ADDED
@@ -0,0 +1,898 @@
1
+ /**
2
+ * Column-based menu navigation (Finder-style)
3
+ *
4
+ * Displays 2 columns at a time, with horizontal scrolling to navigate deeper levels.
5
+ * Each column represents one level of the folder hierarchy.
6
+ *
7
+ * Also supports top menu position for horizontal navigation with dropdowns.
8
+ * Top menu is the default; side menu is used only when explicitly set.
9
+ */
10
+ document.addEventListener('DOMContentLoaded', () => {
11
+ const navMain = document.querySelector('nav#nav-main');
12
+ const navMainTop = document.querySelector('nav#nav-main-top');
13
+ const menuPosition = document.body.dataset.menuPosition || 'top';
14
+
15
+ // If menu position is top (default), handle differently
16
+ if (menuPosition === 'top') {
17
+ initTopMenu();
18
+ return;
19
+ }
20
+
21
+ if (!navMain) return;
22
+
23
+ // Check for custom menu
24
+ const customMenuPath = document.body.dataset.customMenu;
25
+ const isCustomMenu = !!customMenuPath;
26
+
27
+ // State - menu data will be loaded asynchronously
28
+ let menuData = null;
29
+ let menuDataLoaded = false;
30
+ let menuDataLoading = false;
31
+
32
+ // Load menu config from embedded JSON (contains openMenuItems)
33
+ const menuConfigScript = document.getElementById('menu-config');
34
+ let menuConfig = { openMenuItems: [] };
35
+ if (menuConfigScript) {
36
+ try {
37
+ menuConfig = JSON.parse(menuConfigScript.textContent);
38
+ } catch (e) {
39
+ console.error('Failed to parse menu config:', e);
40
+ }
41
+ }
42
+
43
+ // Column navigation state
44
+ let allColumns = []; // Array of column data: [{items: [], parentPath: '', selectedPath: ''}]
45
+ let scrollPosition = 0; // Which column index is the leftmost visible
46
+ let currentDocPath = []; // Path segments to current document
47
+ let currentDocColumnIndex = 0; // Which column contains the current document
48
+
49
+ // DOM element references (set during createMenuStructure)
50
+ let elements = null;
51
+
52
+ // Constants
53
+ const VISIBLE_COLUMNS = 2;
54
+ const COLUMN_WIDTH = 130; // Width of each column in pixels
55
+
56
+ // Helper to check if we're on mobile
57
+ const isMobile = () => window.matchMedia('(max-width: 800px)').matches;
58
+
59
+ /**
60
+ * Load menu data from external JSON file
61
+ */
62
+ async function loadMenuData() {
63
+ if (menuDataLoaded || menuDataLoading) return;
64
+ menuDataLoading = true;
65
+
66
+ try {
67
+ const menuUrl = customMenuPath || '/public/menu-data.json';
68
+ const response = await fetch(menuUrl);
69
+ if (!response.ok) {
70
+ throw new Error(`HTTP ${response.status}`);
71
+ }
72
+ menuData = await response.json();
73
+ menuDataLoaded = true;
74
+
75
+ initializeFromCurrentPage();
76
+ } catch (error) {
77
+ console.error('Failed to load menu data:', error);
78
+ menuDataLoaded = true;
79
+ } finally {
80
+ menuDataLoading = false;
81
+ }
82
+ }
83
+
84
+ // Start loading menu data immediately
85
+ loadMenuData();
86
+
87
+ /**
88
+ * Find item by path string
89
+ */
90
+ function findItemByPath(pathString) {
91
+ if (!menuData) return null;
92
+ if (!pathString) return null;
93
+
94
+ const segments = pathString.split('/').filter(Boolean);
95
+ let items = menuData;
96
+ let item = null;
97
+
98
+ for (let i = 0; i < segments.length; i++) {
99
+ const targetPath = segments.slice(0, i + 1).join('/');
100
+ item = items.find(it => it.path === targetPath);
101
+ if (!item) return null;
102
+ if (item.children && i < segments.length - 1) {
103
+ items = item.children;
104
+ }
105
+ }
106
+ return item;
107
+ }
108
+
109
+ /**
110
+ * Check if an item matches the current page
111
+ */
112
+ function isCurrentPage(item) {
113
+ if (!item.href) return false;
114
+ const currentHref = window.location.pathname;
115
+ const normalizedItemHref = decodeURIComponent(item.href).replace(/\/index\.html$/, '').replace(/\.html$/, '').replace(/\/$/, '');
116
+ const normalizedCurrentHref = decodeURIComponent(currentHref).replace(/\/index\.html$/, '').replace(/\.html$/, '').replace(/\/$/, '');
117
+ return normalizedItemHref === normalizedCurrentHref;
118
+ }
119
+
120
+ /**
121
+ * Build the column structure based on current document path
122
+ */
123
+ function buildColumns() {
124
+ allColumns = [];
125
+
126
+ // Always start with root column
127
+ allColumns.push({
128
+ items: menuData || [],
129
+ parentPath: '',
130
+ selectedPath: currentDocPath.length > 0 ? currentDocPath[0] : null
131
+ });
132
+
133
+ // Build columns for each level of the current document path
134
+ let currentPathString = '';
135
+ for (let i = 0; i < currentDocPath.length; i++) {
136
+ currentPathString = currentDocPath.slice(0, i + 1).join('/');
137
+ const item = findItemByPath(currentPathString);
138
+
139
+ if (item && item.children && item.children.length > 0) {
140
+ const nextSelectedPath = i + 1 < currentDocPath.length
141
+ ? currentDocPath.slice(0, i + 2).join('/')
142
+ : null;
143
+
144
+ allColumns.push({
145
+ items: item.children,
146
+ parentPath: currentPathString,
147
+ selectedPath: nextSelectedPath
148
+ });
149
+ }
150
+ }
151
+
152
+ // Set current doc column index (rightmost column that contains actual content)
153
+ currentDocColumnIndex = allColumns.length - 1;
154
+
155
+ // Default scroll position: show current doc column as rightmost visible
156
+ scrollPosition = Math.max(0, currentDocColumnIndex - VISIBLE_COLUMNS + 1);
157
+ }
158
+
159
+ /**
160
+ * Create the menu DOM structure
161
+ */
162
+ function createMenuStructure() {
163
+ // Clear existing content but preserve any breadcrumb/config scripts
164
+ const configScript = navMain.querySelector('#menu-config');
165
+ navMain.innerHTML = '';
166
+ if (configScript) {
167
+ navMain.appendChild(configScript);
168
+ }
169
+
170
+ // Create main container
171
+ const container = document.createElement('div');
172
+ container.className = 'menu-columns-container';
173
+
174
+ // Create columns wrapper (this scrolls)
175
+ const columnsWrapper = document.createElement('div');
176
+ columnsWrapper.className = 'menu-columns-wrapper';
177
+ container.appendChild(columnsWrapper);
178
+
179
+ navMain.appendChild(container);
180
+
181
+ // Create scroll indicator (shows when current doc is off-screen)
182
+ const scrollIndicator = document.createElement('div');
183
+ scrollIndicator.className = 'menu-scroll-indicator';
184
+ scrollIndicator.innerHTML = '<button class="scroll-to-current" title="Go to current page">Current →</button>';
185
+ scrollIndicator.style.display = 'none';
186
+ navMain.appendChild(scrollIndicator);
187
+
188
+ // Create scroll buttons
189
+ const scrollLeft = document.createElement('button');
190
+ scrollLeft.className = 'menu-scroll-btn scroll-left';
191
+ scrollLeft.innerHTML = '‹';
192
+ scrollLeft.title = 'Scroll left (shallower)';
193
+ navMain.appendChild(scrollLeft);
194
+
195
+ const scrollRight = document.createElement('button');
196
+ scrollRight.className = 'menu-scroll-btn scroll-right';
197
+ scrollRight.innerHTML = '›';
198
+ scrollRight.title = 'Scroll right (deeper)';
199
+ navMain.appendChild(scrollRight);
200
+
201
+ return { container, columnsWrapper, scrollIndicator, scrollLeft, scrollRight };
202
+ }
203
+
204
+ /**
205
+ * Render all columns
206
+ */
207
+ function renderColumns() {
208
+ if (!elements) return;
209
+
210
+ const { columnsWrapper } = elements;
211
+ columnsWrapper.innerHTML = '';
212
+
213
+ for (let i = 0; i < allColumns.length; i++) {
214
+ const col = allColumns[i];
215
+ const columnEl = document.createElement('div');
216
+ columnEl.className = 'menu-column';
217
+ columnEl.dataset.columnIndex = i;
218
+
219
+ const ul = document.createElement('ul');
220
+ ul.className = 'menu-column-list';
221
+
222
+ for (const item of col.items) {
223
+ const li = document.createElement('li');
224
+ li.className = 'menu-column-item';
225
+ li.dataset.path = item.path;
226
+
227
+ // Check if this item is selected (on path to current doc)
228
+ if (item.path === col.selectedPath) {
229
+ li.classList.add('selected');
230
+ }
231
+
232
+ // Check if this is the current page
233
+ if (isCurrentPage(item)) {
234
+ li.classList.add('current-page');
235
+ }
236
+
237
+ // Check if has children
238
+ if (item.hasChildren) {
239
+ li.classList.add('has-children');
240
+ }
241
+
242
+ // Check if this is an index file
243
+ if (item.isIndex) {
244
+ li.classList.add('is-index');
245
+ }
246
+
247
+ // Create the item content
248
+ const row = document.createElement('div');
249
+ row.className = 'menu-column-item-row';
250
+
251
+ // Label (link if has href)
252
+ if (item.href) {
253
+ const link = document.createElement('a');
254
+ link.href = item.href;
255
+ link.className = 'menu-column-label';
256
+ link.textContent = item.label;
257
+ row.appendChild(link);
258
+ } else {
259
+ const span = document.createElement('span');
260
+ span.className = 'menu-column-label';
261
+ span.textContent = item.label;
262
+ row.appendChild(span);
263
+ }
264
+
265
+ // Arrow indicator for folders
266
+ if (item.hasChildren) {
267
+ const arrow = document.createElement('span');
268
+ arrow.className = 'menu-column-arrow';
269
+ arrow.textContent = '›';
270
+ row.appendChild(arrow);
271
+ }
272
+
273
+ li.appendChild(row);
274
+ ul.appendChild(li);
275
+ }
276
+
277
+ columnEl.appendChild(ul);
278
+ columnsWrapper.appendChild(columnEl);
279
+ }
280
+
281
+ // Set wrapper width
282
+ columnsWrapper.style.width = `${allColumns.length * COLUMN_WIDTH}px`;
283
+ }
284
+
285
+ /**
286
+ * Update scroll position (with snap)
287
+ */
288
+ function updateScrollPosition() {
289
+ if (!elements) return;
290
+
291
+ const { columnsWrapper, scrollIndicator, scrollLeft, scrollRight } = elements;
292
+
293
+ // Clamp scroll position
294
+ const maxScroll = Math.max(0, allColumns.length - VISIBLE_COLUMNS);
295
+ scrollPosition = Math.max(0, Math.min(scrollPosition, maxScroll));
296
+
297
+ // Apply transform
298
+ const translateX = -scrollPosition * COLUMN_WIDTH;
299
+ columnsWrapper.style.transform = `translateX(${translateX}px)`;
300
+
301
+ // Update scroll button visibility
302
+ scrollLeft.style.opacity = scrollPosition > 0 ? '1' : '0.3';
303
+ scrollLeft.disabled = scrollPosition <= 0;
304
+
305
+ const canScrollRight = scrollPosition < maxScroll;
306
+ scrollRight.style.opacity = canScrollRight ? '1' : '0.3';
307
+ scrollRight.disabled = !canScrollRight;
308
+
309
+ // Show indicator if current doc is not visible
310
+ const currentDocVisible = scrollPosition <= currentDocColumnIndex &&
311
+ currentDocColumnIndex < scrollPosition + VISIBLE_COLUMNS;
312
+ scrollIndicator.style.display = currentDocVisible ? 'none' : 'flex';
313
+
314
+ // Update indicator direction
315
+ if (!currentDocVisible) {
316
+ const indicatorBtn = scrollIndicator.querySelector('.scroll-to-current');
317
+ if (currentDocColumnIndex < scrollPosition) {
318
+ indicatorBtn.innerHTML = '← Current';
319
+ } else {
320
+ indicatorBtn.innerHTML = 'Current →';
321
+ }
322
+ }
323
+ }
324
+
325
+ /**
326
+ * Navigate into a folder (expand it as a new column)
327
+ */
328
+ function navigateIntoFolder(item) {
329
+ if (!item.hasChildren) return;
330
+
331
+ const pathSegments = item.path.split('/').filter(Boolean);
332
+ currentDocPath = pathSegments;
333
+
334
+ // Rebuild columns with this item expanded
335
+ buildColumns();
336
+ renderColumns();
337
+
338
+ // Scroll to show the new column
339
+ scrollPosition = Math.max(0, allColumns.length - VISIBLE_COLUMNS);
340
+ updateScrollPosition();
341
+
342
+ attachItemHandlers();
343
+ }
344
+
345
+ /**
346
+ * Attach click handlers to menu items
347
+ */
348
+ function attachItemHandlers() {
349
+ if (!elements) return;
350
+
351
+ const items = elements.columnsWrapper.querySelectorAll('.menu-column-item');
352
+
353
+ items.forEach(li => {
354
+ const path = li.dataset.path;
355
+ const item = findItemByPath(path);
356
+ if (!item) return;
357
+
358
+ const row = li.querySelector('.menu-column-item-row');
359
+ const link = li.querySelector('a.menu-column-label');
360
+
361
+ // For folders: clicking anywhere on the row expands
362
+ if (item.hasChildren) {
363
+ row.addEventListener('click', (e) => {
364
+ e.preventDefault();
365
+ e.stopPropagation();
366
+ navigateIntoFolder(item);
367
+ });
368
+
369
+ // But if there's also a link, ctrl/cmd+click should still work
370
+ if (link) {
371
+ link.addEventListener('click', (e) => {
372
+ if (e.ctrlKey || e.metaKey) {
373
+ // Allow normal link behavior for ctrl/cmd+click
374
+ return;
375
+ }
376
+ e.preventDefault();
377
+ e.stopPropagation();
378
+ navigateIntoFolder(item);
379
+ });
380
+ }
381
+ }
382
+ // For files: link navigates normally (no special handling needed)
383
+ });
384
+ }
385
+
386
+ /**
387
+ * Set up scroll event handlers
388
+ */
389
+ function setupScrollHandlers() {
390
+ if (!elements) return;
391
+
392
+ const { container, scrollIndicator, scrollLeft, scrollRight } = elements;
393
+
394
+ // Scroll button handlers
395
+ scrollLeft.addEventListener('click', () => {
396
+ scrollPosition = Math.max(0, scrollPosition - 1);
397
+ updateScrollPosition();
398
+ });
399
+
400
+ scrollRight.addEventListener('click', () => {
401
+ scrollPosition = Math.min(allColumns.length - VISIBLE_COLUMNS, scrollPosition + 1);
402
+ updateScrollPosition();
403
+ });
404
+
405
+ // Scroll to current button
406
+ scrollIndicator.querySelector('.scroll-to-current').addEventListener('click', () => {
407
+ scrollPosition = Math.max(0, currentDocColumnIndex - VISIBLE_COLUMNS + 1);
408
+ updateScrollPosition();
409
+ });
410
+
411
+ // Trackpad/wheel horizontal scrolling with snap
412
+ let accumulatedDelta = 0;
413
+ let scrollTimeout = null;
414
+
415
+ container.addEventListener('wheel', (e) => {
416
+ // Only handle horizontal scroll - let vertical scroll work normally for column scrolling
417
+ const isHorizontalScroll = Math.abs(e.deltaX) > Math.abs(e.deltaY);
418
+
419
+ if (!isHorizontalScroll) {
420
+ // Allow vertical scrolling within columns
421
+ return;
422
+ }
423
+
424
+ const delta = e.deltaX;
425
+
426
+ if (Math.abs(delta) > 0) {
427
+ e.preventDefault();
428
+
429
+ accumulatedDelta += delta;
430
+
431
+ // Clear previous timeout
432
+ if (scrollTimeout) clearTimeout(scrollTimeout);
433
+
434
+ // Snap after scrolling stops
435
+ scrollTimeout = setTimeout(() => {
436
+ if (accumulatedDelta > 50) {
437
+ scrollPosition = Math.min(allColumns.length - VISIBLE_COLUMNS, scrollPosition + 1);
438
+ } else if (accumulatedDelta < -50) {
439
+ scrollPosition = Math.max(0, scrollPosition - 1);
440
+ }
441
+ accumulatedDelta = 0;
442
+ updateScrollPosition();
443
+ }, 100);
444
+ }
445
+ }, { passive: false });
446
+ }
447
+
448
+ /**
449
+ * Main render function
450
+ */
451
+ function renderMenu() {
452
+ if (!menuData) {
453
+ navMain.innerHTML = '<div class="menu-loading">Loading menu...</div>';
454
+ return;
455
+ }
456
+
457
+ buildColumns();
458
+ elements = createMenuStructure();
459
+ renderColumns();
460
+ updateScrollPosition();
461
+ attachItemHandlers();
462
+ setupScrollHandlers();
463
+ }
464
+
465
+ /**
466
+ * Initialize from current page URL
467
+ */
468
+ function initializeFromCurrentPage() {
469
+ const currentHref = window.location.pathname;
470
+ let pathParts = currentHref.split('/').filter(Boolean);
471
+
472
+ // Remove .html extension from the last part
473
+ if (pathParts.length > 0) {
474
+ pathParts[pathParts.length - 1] = pathParts[pathParts.length - 1].replace(/\.html$/, '');
475
+ }
476
+
477
+ // If the last part is "index", treat it as if we're viewing the parent folder
478
+ if (pathParts.length > 0 && pathParts[pathParts.length - 1] === 'index') {
479
+ pathParts = pathParts.slice(0, -1);
480
+ }
481
+
482
+ // Validate path against menu data and build currentDocPath
483
+ currentDocPath = [];
484
+ let testPath = [];
485
+ for (const part of pathParts) {
486
+ testPath.push(part);
487
+ const item = findItemByPath(testPath.join('/'));
488
+ if (item) {
489
+ currentDocPath.push(part);
490
+ } else {
491
+ break;
492
+ }
493
+ }
494
+
495
+ renderMenu();
496
+ }
497
+
498
+ // Mobile menu toggle
499
+ const globalNav = document.querySelector('nav#nav-global');
500
+ const menuButton = globalNav?.querySelector('.menu-button');
501
+
502
+ if (menuButton) {
503
+ function updateButtonIcon(isOpen) {
504
+ menuButton.textContent = isOpen ? '✕' : '☰';
505
+ }
506
+
507
+ menuButton.addEventListener('click', (e) => {
508
+ e.stopPropagation();
509
+
510
+ if (isMobile()) {
511
+ const isNowActive = navMain.classList.toggle('active');
512
+ updateButtonIcon(isNowActive);
513
+ } else {
514
+ navMain.classList.toggle('collapsed');
515
+ }
516
+ });
517
+
518
+ document.addEventListener('click', (e) => {
519
+ if (isMobile() &&
520
+ navMain.classList.contains('active') &&
521
+ !navMain.contains(e.target) &&
522
+ !menuButton.contains(e.target)) {
523
+ navMain.classList.remove('active');
524
+ updateButtonIcon(false);
525
+ }
526
+ });
527
+
528
+ document.addEventListener('keydown', (e) => {
529
+ if (e.key === 'Escape') {
530
+ if (isMobile() && navMain.classList.contains('active')) {
531
+ navMain.classList.remove('active');
532
+ updateButtonIcon(false);
533
+ menuButton.focus();
534
+ }
535
+ }
536
+ });
537
+ }
538
+
539
+ // Initial render (shows loading, then loadMenuData will call initializeFromCurrentPage)
540
+ renderMenu();
541
+ });
542
+
543
+ /**
544
+ * Initialize top menu (horizontal navigation with dropdowns)
545
+ * Also handles: home button on desktop, hamburger → mobile side menu
546
+ */
547
+ function initTopMenu() {
548
+ const navMainTop = document.querySelector('nav#nav-main-top');
549
+ const navMain = document.querySelector('nav#nav-main');
550
+ const globalNav = document.querySelector('nav#nav-global');
551
+ const menuButton = globalNav?.querySelector('.menu-button');
552
+ const customMenuPath = document.body.dataset.customMenu;
553
+
554
+ if (!navMainTop) return;
555
+
556
+ // Determine which URL to load — custom menu or default auto-menu
557
+ const menuUrl = customMenuPath || '/public/menu-data.json';
558
+
559
+ // Load menu data and render both top menu and mobile menu
560
+ fetch(menuUrl)
561
+ .then(response => response.json())
562
+ .then(data => {
563
+ const menuData = data.menuData || data;
564
+ renderTopMenu(navMainTop, menuData);
565
+ buildMobileMenu(menuData, navMain);
566
+ })
567
+ .catch(error => {
568
+ console.error('Failed to load top menu data:', error);
569
+ });
570
+
571
+ // Set up home button (desktop) / hamburger (mobile)
572
+ setupMenuButton(menuButton, navMain);
573
+ }
574
+
575
+ /**
576
+ * Set up the menu button:
577
+ * - Desktop: shows 🏠 (home icon), navigates to "/" on click
578
+ * - Mobile: shows ☰ (hamburger), toggles mobile side menu on click
579
+ */
580
+ function setupMenuButton(menuButton, navMain) {
581
+ if (!menuButton) return;
582
+
583
+ const isMobile = () => window.matchMedia('(max-width: 50rem)').matches;
584
+
585
+ function updateButtonIcon() {
586
+ if (isMobile()) {
587
+ const isActive = navMain?.classList.contains('active');
588
+ menuButton.textContent = isActive ? '✕' : '☰';
589
+ menuButton.setAttribute('aria-label', isActive ? 'Close menu' : 'Menu');
590
+ } else {
591
+ menuButton.textContent = '🏠';
592
+ menuButton.setAttribute('aria-label', 'Home');
593
+ }
594
+ }
595
+
596
+ // Set initial icon
597
+ updateButtonIcon();
598
+
599
+ // Update icon on resize
600
+ window.addEventListener('resize', updateButtonIcon);
601
+
602
+ menuButton.addEventListener('click', (e) => {
603
+ e.stopPropagation();
604
+
605
+ if (isMobile()) {
606
+ // Mobile: toggle side menu
607
+ if (navMain) {
608
+ const isNowActive = navMain.classList.toggle('active');
609
+ menuButton.textContent = isNowActive ? '✕' : '☰';
610
+ menuButton.setAttribute('aria-label', isNowActive ? 'Close menu' : 'Menu');
611
+ }
612
+ } else {
613
+ // Desktop: navigate to home
614
+ window.location.href = '/';
615
+ }
616
+ });
617
+
618
+ // Close mobile menu on outside click
619
+ document.addEventListener('click', (e) => {
620
+ if (isMobile() && navMain?.classList.contains('active') &&
621
+ !navMain.contains(e.target) && !menuButton.contains(e.target)) {
622
+ navMain.classList.remove('active');
623
+ updateButtonIcon();
624
+ }
625
+ });
626
+
627
+ // Close mobile menu on Escape
628
+ document.addEventListener('keydown', (e) => {
629
+ if (e.key === 'Escape' && isMobile() && navMain?.classList.contains('active')) {
630
+ navMain.classList.remove('active');
631
+ updateButtonIcon();
632
+ menuButton.focus();
633
+ }
634
+ });
635
+ }
636
+
637
+ /**
638
+ * Build the mobile side menu from the same data as the top menu.
639
+ * Repurposes #nav-main as a vertical list with a "Home" link at top.
640
+ */
641
+ function buildMobileMenu(menuData, navMain) {
642
+ if (!navMain) return;
643
+
644
+ // Clear existing content
645
+ navMain.innerHTML = '';
646
+
647
+ const ul = document.createElement('ul');
648
+ ul.className = 'mobile-menu-list';
649
+
650
+ // Add "Home" item at the top
651
+ const homeLi = document.createElement('li');
652
+ homeLi.className = 'mobile-menu-item mobile-menu-home';
653
+ const homeLink = document.createElement('a');
654
+ homeLink.href = '/';
655
+ homeLink.className = 'mobile-menu-label';
656
+ homeLink.textContent = '🏠 Home';
657
+ homeLi.appendChild(homeLink);
658
+ ul.appendChild(homeLi);
659
+
660
+ // Build menu items recursively
661
+ buildMobileMenuItems(menuData, ul, 0);
662
+
663
+ navMain.appendChild(ul);
664
+ }
665
+
666
+ /**
667
+ * Recursively build mobile menu items as a flat indented list
668
+ */
669
+ function buildMobileMenuItems(items, parentUl, depth) {
670
+ for (const item of items) {
671
+ const li = document.createElement('li');
672
+ li.className = 'mobile-menu-item';
673
+ if (depth > 0) {
674
+ li.classList.add('mobile-menu-depth-' + Math.min(depth, 4));
675
+ }
676
+
677
+ const el = item.href
678
+ ? document.createElement('a')
679
+ : document.createElement('span');
680
+ el.className = 'mobile-menu-label';
681
+ el.textContent = item.label;
682
+ if (item.href) el.href = item.href;
683
+
684
+ // Highlight current page
685
+ if (item.href && isCurrentTopMenuPage(item.href)) {
686
+ li.classList.add('mobile-menu-current');
687
+ }
688
+
689
+ li.appendChild(el);
690
+ parentUl.appendChild(li);
691
+
692
+ // Recurse into children
693
+ if (item.children && item.children.length > 0) {
694
+ buildMobileMenuItems(item.children, parentUl, depth + 1);
695
+ }
696
+ }
697
+ }
698
+
699
+ /**
700
+ * Check if an href matches the current page
701
+ */
702
+ function isCurrentTopMenuPage(href) {
703
+ if (!href || href === '#') return false;
704
+ const current = decodeURIComponent(window.location.pathname)
705
+ .replace(/\/index\.html$/, '').replace(/\.html$/, '').replace(/\/$/, '');
706
+ const target = decodeURIComponent(href)
707
+ .replace(/\/index\.html$/, '').replace(/\.html$/, '').replace(/\/$/, '');
708
+ return current === target;
709
+ }
710
+
711
+ /**
712
+ * Render the top navigation menu
713
+ */
714
+ function renderTopMenu(container, menuData) {
715
+ const ul = document.createElement('ul');
716
+ ul.className = 'top-menu-level';
717
+
718
+ menuData.forEach(item => {
719
+ const li = document.createElement('li');
720
+ li.className = 'top-menu-item';
721
+ if (item.hasChildren || (item.children && item.children.length > 0)) {
722
+ li.classList.add('has-dropdown');
723
+ }
724
+
725
+ // Create label (link or span)
726
+ const label = item.href
727
+ ? document.createElement('a')
728
+ : document.createElement('span');
729
+ label.className = 'top-menu-label';
730
+ label.textContent = item.label;
731
+ if (item.href) {
732
+ label.href = item.href;
733
+ }
734
+ li.appendChild(label);
735
+
736
+ // Create dropdown if has children
737
+ if (item.children && item.children.length > 0) {
738
+ const dropdown = createTopMenuDropdown(item.children);
739
+ li.appendChild(dropdown);
740
+ }
741
+
742
+ ul.appendChild(li);
743
+ });
744
+
745
+ container.appendChild(ul);
746
+ }
747
+
748
+ /**
749
+ * Create a dropdown menu for top navigation
750
+ */
751
+ function createTopMenuDropdown(items) {
752
+ const ul = document.createElement('ul');
753
+ ul.className = 'top-menu-dropdown';
754
+
755
+ items.forEach(item => {
756
+ const li = document.createElement('li');
757
+ li.className = 'dropdown-item';
758
+ if (item.hasChildren || (item.children && item.children.length > 0)) {
759
+ li.classList.add('has-flyout');
760
+ }
761
+
762
+ // Create label
763
+ const label = item.href
764
+ ? document.createElement('a')
765
+ : document.createElement('span');
766
+ label.className = 'dropdown-label';
767
+ label.textContent = item.label;
768
+ if (item.href) {
769
+ label.href = item.href;
770
+ }
771
+ li.appendChild(label);
772
+
773
+ // Add flyout indicator if has children
774
+ if (item.children && item.children.length > 0) {
775
+ const indicator = document.createElement('span');
776
+ indicator.className = 'flyout-indicator';
777
+ indicator.textContent = '▶';
778
+ label.appendChild(indicator);
779
+
780
+ // Create flyout submenu
781
+ const flyout = createTopMenuFlyout(item.children);
782
+ li.appendChild(flyout);
783
+ }
784
+
785
+ ul.appendChild(li);
786
+ });
787
+
788
+ return ul;
789
+ }
790
+
791
+ /**
792
+ * Create a flyout submenu for nested items
793
+ */
794
+ function createTopMenuFlyout(items) {
795
+ const ul = document.createElement('ul');
796
+ ul.className = 'top-menu-flyout';
797
+
798
+ items.forEach(item => {
799
+ const li = document.createElement('li');
800
+ li.className = 'dropdown-item';
801
+ if (item.hasChildren || (item.children && item.children.length > 0)) {
802
+ li.classList.add('has-flyout');
803
+ }
804
+
805
+ const label = item.href
806
+ ? document.createElement('a')
807
+ : document.createElement('span');
808
+ label.className = 'dropdown-label';
809
+ label.textContent = item.label;
810
+ if (item.href) {
811
+ label.href = item.href;
812
+ }
813
+ li.appendChild(label);
814
+
815
+ // Recursive flyout for deeper levels
816
+ if (item.children && item.children.length > 0) {
817
+ const indicator = document.createElement('span');
818
+ indicator.className = 'flyout-indicator';
819
+ indicator.textContent = '▶';
820
+ label.appendChild(indicator);
821
+
822
+ const flyout = createTopMenuFlyout(item.children);
823
+ li.appendChild(flyout);
824
+ }
825
+
826
+ ul.appendChild(li);
827
+ });
828
+
829
+ return ul;
830
+ }
831
+
832
+ // (Search functionality is now handled by widgets.js)
833
+
834
+ /**
835
+ * Initialize flyout positioning for top menu
836
+ * Flyouts use position:fixed to avoid being clipped by parent overflow
837
+ */
838
+ function initFlyoutPositioning() {
839
+ const nav = document.querySelector('nav#nav-main-top');
840
+ if (!nav) return;
841
+
842
+ // Use event delegation for all flyout triggers
843
+ nav.addEventListener('mouseenter', (e) => {
844
+ const trigger = e.target.closest('.dropdown-item.has-flyout');
845
+ if (!trigger) return;
846
+
847
+ const flyout = trigger.querySelector(':scope > .top-menu-flyout');
848
+ if (!flyout) return;
849
+
850
+ positionFlyout(trigger, flyout);
851
+ }, true);
852
+ }
853
+
854
+ /**
855
+ * Position a flyout menu relative to its trigger element
856
+ */
857
+ function positionFlyout(trigger, flyout) {
858
+ const triggerRect = trigger.getBoundingClientRect();
859
+ const viewportWidth = window.innerWidth;
860
+ const viewportHeight = window.innerHeight;
861
+ const navHeight = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--global-nav-height')) || 48;
862
+
863
+ // Default: position to the right of the trigger
864
+ let left = triggerRect.right;
865
+ let top = triggerRect.top;
866
+
867
+ // Temporarily show to measure
868
+ flyout.style.visibility = 'hidden';
869
+ flyout.style.display = 'block';
870
+ const flyoutRect = flyout.getBoundingClientRect();
871
+ flyout.style.display = '';
872
+ flyout.style.visibility = '';
873
+
874
+ // If flyout would overflow right edge, flip to left side
875
+ if (left + flyoutRect.width > viewportWidth - 10) {
876
+ left = triggerRect.left - flyoutRect.width;
877
+ }
878
+
879
+ // Keep flyout within vertical bounds
880
+ // Clamp to bottom of nav bar (no gap for top items)
881
+ if (top < navHeight) {
882
+ top = navHeight;
883
+ }
884
+ // Prevent overflow at bottom of viewport
885
+ if (top + flyoutRect.height > viewportHeight - 10) {
886
+ top = viewportHeight - flyoutRect.height - 10;
887
+ }
888
+
889
+ flyout.style.left = `${left}px`;
890
+ flyout.style.top = `${top}px`;
891
+ }
892
+
893
+ // Initialize flyout positioning when DOM is ready
894
+ if (document.readyState === 'loading') {
895
+ document.addEventListener('DOMContentLoaded', initFlyoutPositioning);
896
+ } else {
897
+ initFlyoutPositioning();
898
+ }
@@ -796,7 +796,7 @@ nav#nav-main-top .dropdown-label {
796
796
  }
797
797
 
798
798
  nav#nav-main-top .dropdown-label:hover {
799
- background-color: var(--nav-top-bg);
799
+ background-color: rgba(255, 255, 255, 0.1);
800
800
  }
801
801
 
802
802
  nav#nav-main-top a.dropdown-label:hover {
@@ -810,14 +810,12 @@ nav#nav-main-top .flyout-indicator {
810
810
  margin-left: 8px;
811
811
  }
812
812
 
813
- /* Flyout menu (nested children) */
813
+ /* Flyout menu (nested children) - uses fixed positioning to avoid clipping by parent overflow */
814
814
  nav#nav-main-top .top-menu-flyout {
815
815
  display: none;
816
- position: absolute;
817
- left: 100%;
818
- top: -1px;
816
+ position: fixed;
819
817
  min-width: 200px;
820
- max-height: calc(100vh - var(--global-nav-height) - 20px);
818
+ max-height: calc(100vh - var(--global-nav-height) - 40px);
821
819
  overflow-y: auto;
822
820
  background-color: var(--widget-bg);
823
821
  border: 1px solid var(--widget-border);
@@ -827,9 +825,10 @@ nav#nav-main-top .top-menu-flyout {
827
825
  margin: 0;
828
826
  padding: 0;
829
827
  z-index: 1006;
828
+ /* JS will set top/left via inline styles */
830
829
  }
831
830
 
832
- /* Show flyout on hover */
831
+ /* Show flyout on hover - JS handles positioning */
833
832
  nav#nav-main-top .dropdown-item.has-flyout:hover > .top-menu-flyout {
834
833
  display: block;
835
834
  }
@@ -829,4 +829,70 @@ function createTopMenuFlyout(items) {
829
829
  return ul;
830
830
  }
831
831
 
832
- // (Search functionality is now handled by widgets.js)
832
+ // (Search functionality is now handled by widgets.js)
833
+
834
+ /**
835
+ * Initialize flyout positioning for top menu
836
+ * Flyouts use position:fixed to avoid being clipped by parent overflow
837
+ */
838
+ function initFlyoutPositioning() {
839
+ const nav = document.querySelector('nav#nav-main-top');
840
+ if (!nav) return;
841
+
842
+ // Use event delegation for all flyout triggers
843
+ nav.addEventListener('mouseenter', (e) => {
844
+ const trigger = e.target.closest('.dropdown-item.has-flyout');
845
+ if (!trigger) return;
846
+
847
+ const flyout = trigger.querySelector(':scope > .top-menu-flyout');
848
+ if (!flyout) return;
849
+
850
+ positionFlyout(trigger, flyout);
851
+ }, true);
852
+ }
853
+
854
+ /**
855
+ * Position a flyout menu relative to its trigger element
856
+ */
857
+ function positionFlyout(trigger, flyout) {
858
+ const triggerRect = trigger.getBoundingClientRect();
859
+ const viewportWidth = window.innerWidth;
860
+ const viewportHeight = window.innerHeight;
861
+ const navHeight = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--global-nav-height')) || 48;
862
+
863
+ // Default: position to the right of the trigger
864
+ let left = triggerRect.right;
865
+ let top = triggerRect.top;
866
+
867
+ // Temporarily show to measure
868
+ flyout.style.visibility = 'hidden';
869
+ flyout.style.display = 'block';
870
+ const flyoutRect = flyout.getBoundingClientRect();
871
+ flyout.style.display = '';
872
+ flyout.style.visibility = '';
873
+
874
+ // If flyout would overflow right edge, flip to left side
875
+ if (left + flyoutRect.width > viewportWidth - 10) {
876
+ left = triggerRect.left - flyoutRect.width;
877
+ }
878
+
879
+ // Keep flyout within vertical bounds
880
+ // Clamp to bottom of nav bar (no gap for top items)
881
+ if (top < navHeight) {
882
+ top = navHeight;
883
+ }
884
+ // Prevent overflow at bottom of viewport
885
+ if (top + flyoutRect.height > viewportHeight - 10) {
886
+ top = viewportHeight - flyoutRect.height - 10;
887
+ }
888
+
889
+ flyout.style.left = `${left}px`;
890
+ flyout.style.top = `${top}px`;
891
+ }
892
+
893
+ // Initialize flyout positioning when DOM is ready
894
+ if (document.readyState === 'loading') {
895
+ document.addEventListener('DOMContentLoaded', initFlyoutPositioning);
896
+ } else {
897
+ initFlyoutPositioning();
898
+ }
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.77.0",
5
+ "version": "0.79.0",
6
6
  "description": "static site generator from MD/wikitext/YML",
7
7
  "main": "lib/index.js",
8
8
  "bin": {
@@ -72,7 +72,9 @@
72
72
  "CHANGELOG.md"
73
73
  ],
74
74
  "pnpm": {
75
- "onlyBuiltDependencies": ["esbuild"]
75
+ "onlyBuiltDependencies": [
76
+ "esbuild"
77
+ ]
76
78
  },
77
79
  "publishConfig": {
78
80
  "access": "public",
@@ -411,6 +411,14 @@ function buildMenuData(tree, source, validPaths, parentPath = '', includeDebug =
411
411
  * When a folder contains only an index file (index.md) or a foldername-matching
412
412
  * file (e.g. Arcanist/Arcanist.md) and no other children, the folder is replaced
413
413
  * with a direct link to that document using the folder's label.
414
+ *
415
+ * Conditions for collapse:
416
+ * - Exactly one child
417
+ * - That child is an index-like file (isIndex: true)
418
+ * - The child has no children (i.e., not a subfolder)
419
+ *
420
+ * This ensures folders with subfolders are never collapsed, even if they only
421
+ * have 0-1 regular documents.
414
422
  */
415
423
  function collapseSingleDocFolders(items) {
416
424
  return items.map(item => {
@@ -419,18 +427,20 @@ function collapseSingleDocFolders(items) {
419
427
  // Recurse first so nested single-doc folders are collapsed bottom-up
420
428
  item.children = collapseSingleDocFolders(item.children);
421
429
 
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
- };
430
+ // Only collapse if there is exactly one child that is an index-like file
431
+ // Collapsed subfolders have isIndex: false, so they won't trigger collapse
432
+ // Folders with any remaining subfolders (hasChildren: true) won't collapse
433
+ if (item.children.length === 1) {
434
+ const onlyChild = item.children[0];
435
+ if (onlyChild.isIndex && !onlyChild.hasChildren) {
436
+ return {
437
+ ...item,
438
+ hasChildren: false,
439
+ children: undefined,
440
+ href: onlyChild.href || item.href,
441
+ isIndex: false,
442
+ };
443
+ }
434
444
  }
435
445
 
436
446
  return item;
@@ -20,10 +20,58 @@ const FOLDER_ICON = '📁';
20
20
  const DOCUMENT_ICON = '📄';
21
21
  const HOME_ICON = '🏠';
22
22
 
23
+ /**
24
+ * Check if a menu item represents an index-like file.
25
+ * Index-like files are: index, home, or {foldername}.html
26
+ *
27
+ * IMPORTANT: The index file must be DIRECTLY within the parent folder,
28
+ * not in a nested subfolder. This prevents collapsed nested folders
29
+ * from being treated as index files for their parent.
30
+ *
31
+ * @param {object} item - Menu item
32
+ * @param {string} parentLabel - The parent folder's label
33
+ * @returns {boolean}
34
+ */
35
+ function isIndexLikeItem(item, parentLabel) {
36
+ if (!item.href) return false;
37
+
38
+ const hrefLower = item.href.toLowerCase();
39
+ const parts = hrefLower.split('/').filter(Boolean);
40
+
41
+ // Need at least 2 parts: parent folder and filename
42
+ if (parts.length < 2) return false;
43
+
44
+ const filename = parts[parts.length - 1]?.replace(/\.html$/, '') || '';
45
+ const parentFolder = parts[parts.length - 2] || '';
46
+
47
+ // The parent folder in the href must match the expected parent label
48
+ // This prevents /foo/bar/nested/index.html from matching as index for /foo/bar/
49
+ if (parentLabel && parentFolder !== parentLabel.toLowerCase()) {
50
+ return false;
51
+ }
52
+
53
+ // Check for index or home
54
+ if (filename === 'index' || filename === 'home') return true;
55
+
56
+ // Check if filename matches parent folder name (e.g., 'arcanist.html' in 'Arcanist/' folder)
57
+ if (parentLabel && filename === parentLabel.toLowerCase()) return true;
58
+
59
+ return false;
60
+ }
61
+
23
62
  /**
24
63
  * Collapse single-document folders into direct links.
25
- * If a folder's only children are index-like files (no sub-folders),
26
- * collapse it to a direct link using the folder's label and the first child's href.
64
+ * When a folder contains only an index file (index.md) or a foldername-matching
65
+ * file and no subfolders, collapse it to a direct link using the folder's label.
66
+ *
67
+ * Conditions for collapse:
68
+ * - Exactly one child
69
+ * - That child is an index-like file (index, home, or {foldername})
70
+ * - The child has no children (i.e., not a subfolder)
71
+ *
72
+ * This ensures folders with subfolders are never collapsed, even if they only
73
+ * have 0-1 regular documents.
74
+ *
27
75
  * @param {Array} items - Menu items
28
76
  * @returns {Array} - Processed menu items
29
77
  */
@@ -34,13 +82,11 @@ function collapseSingleDocFolders(items) {
34
82
  // Recurse first so nested single-doc folders are collapsed bottom-up
35
83
  item.children = collapseSingleDocFolders(item.children);
36
84
 
37
- // Check if all children are non-folder items (i.e., no child has children)
38
- const hasSubfolders = item.children.some(c => c.hasChildren);
39
- if (!hasSubfolders && item.children.length > 0) {
40
- // All children are leaf files if there's only one, collapse to a direct link
41
- // For index-like content (single child), collapse the folder entirely
42
- if (item.children.length === 1) {
43
- const onlyChild = item.children[0];
85
+ // Only collapse if there is exactly one child that is an index-like file
86
+ if (item.children.length === 1) {
87
+ const onlyChild = item.children[0];
88
+ // Check that the child is not a subfolder and is an index-like file
89
+ if (!onlyChild.hasChildren && isIndexLikeItem(onlyChild, item.label)) {
44
90
  return {
45
91
  ...item,
46
92
  hasChildren: false,
@@ -17,6 +17,7 @@ import {
17
17
  saveHashCache,
18
18
  needsRegeneration,
19
19
  updateHash,
20
+ getUrsaDir,
20
21
  } from "../helper/contentHash.js";
21
22
  import {
22
23
  buildValidPaths,
@@ -32,7 +33,7 @@ import { bundleMetaTemplateAssets, bundleDocumentCss, bundleDocumentJs, clearMet
32
33
  import { buildFullTextIndex, buildIncrementalIndex, loadIndexCache, saveIndexCache } from "../helper/fullTextIndex.js";
33
34
  import { dependencyTracker } from "../helper/dependencyTracker.js";
34
35
  import { CacheBustHashMap } from "../helper/build/cacheBust.js";
35
- import { copy as copyDir, emptyDir, outputFile } from "fs-extra";
36
+ import { copy as copyDir, emptyDir, outputFile, remove } from "fs-extra";
36
37
  import { basename, dirname, extname, join, parse, resolve } from "path";
37
38
  import { URL } from "url";
38
39
  import o2x from "object-to-xml";
@@ -131,9 +132,12 @@ export async function generate({
131
132
  // Initialize dependency tracker for this build
132
133
  dependencyTracker.init(source);
133
134
 
134
- // Clear output directory when --clean is specified
135
+ // Clear output directory and cache when --clean is specified
135
136
  if (_clean) {
136
137
  progress.startTimer('Clean');
138
+ const ursaDir = getUrsaDir(source);
139
+ progress.logTimed(`Clean build: deleting cache folder ${ursaDir}`);
140
+ await remove(ursaDir);
137
141
  progress.logTimed(`Clean build: clearing output directory ${output}`);
138
142
  await emptyDir(output);
139
143
  progress.logTimed(`Clean complete [${progress.stopTimer('Clean')}]`);