@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 +14 -0
- package/README.md +1 -1
- package/meta/default.css +6 -7
- package/meta/menu.js +898 -0
- package/meta/templates/default-template/default.css +6 -7
- package/meta/templates/default-template/menu.js +67 -1
- package/package.json +4 -2
- package/src/helper/automenu.js +22 -12
- package/src/helper/customMenu.js +55 -9
- package/src/jobs/generate.js +6 -2
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` -
|
|
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:
|
|
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:
|
|
756
|
-
left: 100%;
|
|
757
|
-
top: -1px;
|
|
755
|
+
position: fixed;
|
|
758
756
|
min-width: 200px;
|
|
759
|
-
max-height: calc(100vh - var(--global-nav-height) -
|
|
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:
|
|
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:
|
|
817
|
-
left: 100%;
|
|
818
|
-
top: -1px;
|
|
816
|
+
position: fixed;
|
|
819
817
|
min-width: 200px;
|
|
820
|
-
max-height: calc(100vh - var(--global-nav-height) -
|
|
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.
|
|
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": [
|
|
75
|
+
"onlyBuiltDependencies": [
|
|
76
|
+
"esbuild"
|
|
77
|
+
]
|
|
76
78
|
},
|
|
77
79
|
"publishConfig": {
|
|
78
80
|
"access": "public",
|
package/src/helper/automenu.js
CHANGED
|
@@ -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
|
-
//
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
const
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
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;
|
package/src/helper/customMenu.js
CHANGED
|
@@ -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
|
-
*
|
|
26
|
-
* collapse it to a direct link using the folder's label
|
|
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
|
-
//
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
//
|
|
41
|
-
|
|
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,
|
package/src/jobs/generate.js
CHANGED
|
@@ -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')}]`);
|