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