@kenjura/ursa 0.53.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 +13 -1
- 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/autoIndex.js +197 -0
- package/src/helper/build/batch.js +19 -0
- package/src/helper/build/cacheBust.js +62 -0
- package/src/helper/build/excludeFilter.js +67 -0
- package/src/helper/build/footer.js +113 -0
- package/src/helper/build/index.js +13 -0
- package/src/helper/build/menu.js +103 -0
- package/src/helper/build/metadata.js +30 -0
- package/src/helper/build/pathUtils.js +13 -0
- package/src/helper/build/progress.js +35 -0
- package/src/helper/build/templates.js +30 -0
- package/src/helper/build/titleCase.js +7 -0
- package/src/helper/build/watchCache.js +26 -0
- package/src/helper/customMenu.js +303 -0
- package/src/jobs/generate.js +97 -561
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
|
+
# 0.55.0
|
|
2
|
+
2025-12-21
|
|
3
|
+
|
|
4
|
+
- custom menus (menu.md|txt) override the automenu when present
|
|
5
|
+
|
|
6
|
+
# 0.54.0
|
|
7
|
+
2025-12-21
|
|
8
|
+
|
|
9
|
+
- added cache-busting timestamps to static files
|
|
10
|
+
- cleaned up generate.js by moving helper functions to separate files
|
|
11
|
+
|
|
12
|
+
|
|
1
13
|
# 0.53.0
|
|
2
|
-
2025-
|
|
14
|
+
2025-12-21
|
|
3
15
|
|
|
4
16
|
### Menu Size Optimization
|
|
5
17
|
- **External Menu JSON**: Menu data is now stored in `/public/menu-data.json` instead of being embedded in every HTML file. This dramatically reduces HTML file sizes for sites with large folder structures (e.g., from 2-3MB per file down to ~50KB).
|
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
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// Auto-index generation helpers for build
|
|
2
|
+
import { existsSync } from "fs";
|
|
3
|
+
import { readdir, readFile } from "fs/promises";
|
|
4
|
+
import { basename, dirname, extname, join } from "path";
|
|
5
|
+
import { outputFile } from "fs-extra";
|
|
6
|
+
import { findStyleCss } from "../findStyleCss.js";
|
|
7
|
+
import { toTitleCase } from "./titleCase.js";
|
|
8
|
+
import { addTimestampToHtmlStaticRefs } from "./cacheBust.js";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Generate automatic index.html files for folders that don't have one
|
|
12
|
+
* @param {string} output - Output directory path
|
|
13
|
+
* @param {string[]} directories - List of source directories
|
|
14
|
+
* @param {string} source - Source directory path
|
|
15
|
+
* @param {object} templates - Template map
|
|
16
|
+
* @param {string} menu - Rendered menu HTML
|
|
17
|
+
* @param {string} footer - Footer HTML
|
|
18
|
+
* @param {string[]} generatedArticles - List of source article paths that were generated
|
|
19
|
+
* @param {Set<string>} copiedCssFiles - Set of CSS files already copied to output
|
|
20
|
+
* @param {Set<string>} existingHtmlFiles - Set of existing HTML files in source (relative paths)
|
|
21
|
+
* @param {string} cacheBustTimestamp - Cache-busting timestamp
|
|
22
|
+
* @param {object} progress - Progress reporter instance
|
|
23
|
+
*/
|
|
24
|
+
export async function generateAutoIndices(output, directories, source, templates, menu, footer, generatedArticles, copiedCssFiles, existingHtmlFiles, cacheBustTimestamp, progress) {
|
|
25
|
+
// Alternate index file names to look for (in priority order)
|
|
26
|
+
const INDEX_ALTERNATES = ['_index.html', 'home.html', '_home.html'];
|
|
27
|
+
|
|
28
|
+
// Normalize paths (remove trailing slashes for consistent replacement)
|
|
29
|
+
const sourceNorm = source.replace(/\/+$/, '');
|
|
30
|
+
const outputNorm = output.replace(/\/+$/, '');
|
|
31
|
+
|
|
32
|
+
// Build set of directories that already have an index.html from a source index.md/txt/yml
|
|
33
|
+
const dirsWithSourceIndex = new Set();
|
|
34
|
+
for (const articlePath of generatedArticles) {
|
|
35
|
+
const base = basename(articlePath, extname(articlePath));
|
|
36
|
+
if (base === 'index') {
|
|
37
|
+
const dir = dirname(articlePath);
|
|
38
|
+
const outputDir = dir.replace(sourceNorm, outputNorm);
|
|
39
|
+
dirsWithSourceIndex.add(outputDir);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Get all output directories (including root)
|
|
44
|
+
const outputDirs = new Set([outputNorm]);
|
|
45
|
+
for (const dir of directories) {
|
|
46
|
+
// Handle both with and without trailing slash in source
|
|
47
|
+
const outputDir = dir.replace(sourceNorm, outputNorm);
|
|
48
|
+
outputDirs.add(outputDir);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
let generatedCount = 0;
|
|
52
|
+
let renamedCount = 0;
|
|
53
|
+
let skippedHtmlCount = 0;
|
|
54
|
+
|
|
55
|
+
for (const dir of outputDirs) {
|
|
56
|
+
const indexPath = join(dir, 'index.html');
|
|
57
|
+
|
|
58
|
+
// Skip if this directory had a source index.md/txt/yml that was already processed
|
|
59
|
+
if (dirsWithSourceIndex.has(dir)) {
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Check if there's an existing index.html in the source directory (don't overwrite it)
|
|
64
|
+
const sourceDir = dir.replace(outputNorm, sourceNorm);
|
|
65
|
+
const relativeIndexPath = join(sourceDir, 'index.html').replace(sourceNorm + '/', '');
|
|
66
|
+
if (existingHtmlFiles && existingHtmlFiles.has(relativeIndexPath)) {
|
|
67
|
+
skippedHtmlCount++;
|
|
68
|
+
continue; // Don't overwrite existing source HTML
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Skip if index.html already exists in output (e.g., created by previous run)
|
|
72
|
+
if (existsSync(indexPath)) {
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Get folder name for (foldername).html check
|
|
77
|
+
const folderName = basename(dir);
|
|
78
|
+
const folderNameAlternate = `${folderName}.html`;
|
|
79
|
+
|
|
80
|
+
// Check for alternate index files
|
|
81
|
+
let foundAlternate = null;
|
|
82
|
+
for (const alt of [...INDEX_ALTERNATES, folderNameAlternate]) {
|
|
83
|
+
const altPath = join(dir, alt);
|
|
84
|
+
if (existsSync(altPath)) {
|
|
85
|
+
foundAlternate = altPath;
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (foundAlternate) {
|
|
91
|
+
// Rename/copy alternate to index.html
|
|
92
|
+
try {
|
|
93
|
+
const content = await readFile(foundAlternate, 'utf8');
|
|
94
|
+
await outputFile(indexPath, content);
|
|
95
|
+
renamedCount++;
|
|
96
|
+
progress.status('Auto-index', `Promoted ${basename(foundAlternate)} → index.html in ${dir.replace(outputNorm, '') || '/'}`);
|
|
97
|
+
} catch (e) {
|
|
98
|
+
progress.log(`Error promoting ${foundAlternate} to index.html: ${e.message}`);
|
|
99
|
+
}
|
|
100
|
+
} else {
|
|
101
|
+
// Generate a simple index listing direct children
|
|
102
|
+
try {
|
|
103
|
+
const children = await readdir(dir, { withFileTypes: true });
|
|
104
|
+
|
|
105
|
+
// Filter to only include relevant files and folders
|
|
106
|
+
const items = children
|
|
107
|
+
.filter(child => {
|
|
108
|
+
// Skip hidden files and index alternates we just checked
|
|
109
|
+
if (child.name.startsWith('.')) return false;
|
|
110
|
+
if (child.name === 'index.html') return false;
|
|
111
|
+
// Include directories and html files
|
|
112
|
+
return child.isDirectory() || child.name.endsWith('.html');
|
|
113
|
+
})
|
|
114
|
+
.map(child => {
|
|
115
|
+
const isDir = child.isDirectory();
|
|
116
|
+
const name = isDir ? child.name : child.name.replace('.html', '');
|
|
117
|
+
const href = isDir ? `${child.name}/` : child.name;
|
|
118
|
+
const displayName = toTitleCase(name);
|
|
119
|
+
const icon = isDir ? '📁' : '📄';
|
|
120
|
+
return `<li>${icon} <a href="${href}">${displayName}</a></li>`;
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
if (items.length === 0) {
|
|
124
|
+
// Empty folder, skip generating index
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const folderDisplayName = dir === outputNorm ? 'Home' : toTitleCase(folderName);
|
|
129
|
+
const indexHtml = `<h1>${folderDisplayName}</h1>\n<ul class="auto-index">\n${items.join('\n')}\n</ul>`;
|
|
130
|
+
|
|
131
|
+
const template = templates["default-template"];
|
|
132
|
+
if (!template) {
|
|
133
|
+
progress.log(`Warning: No default template for auto-index in ${dir}`);
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Find nearest style.css for this directory
|
|
138
|
+
let styleLink = "";
|
|
139
|
+
try {
|
|
140
|
+
// Map output dir back to source dir to find style.css
|
|
141
|
+
const sourceDir = dir.replace(outputNorm, sourceNorm);
|
|
142
|
+
const cssPath = await findStyleCss(sourceDir);
|
|
143
|
+
if (cssPath) {
|
|
144
|
+
// Calculate output path for the CSS file (mirrors source structure)
|
|
145
|
+
const cssOutputPath = cssPath.replace(sourceNorm, outputNorm);
|
|
146
|
+
const cssUrlPath = '/' + cssPath.replace(sourceNorm, '');
|
|
147
|
+
|
|
148
|
+
// Copy CSS file if not already copied
|
|
149
|
+
if (!copiedCssFiles.has(cssPath)) {
|
|
150
|
+
const cssContent = await readFile(cssPath, 'utf8');
|
|
151
|
+
await outputFile(cssOutputPath, cssContent);
|
|
152
|
+
copiedCssFiles.add(cssPath);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Generate link tag
|
|
156
|
+
styleLink = `<link rel="stylesheet" href="${cssUrlPath}" />`;
|
|
157
|
+
}
|
|
158
|
+
} catch (e) {
|
|
159
|
+
// ignore CSS lookup errors
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
let finalHtml = template;
|
|
163
|
+
const replacements = {
|
|
164
|
+
"${menu}": menu,
|
|
165
|
+
"${body}": indexHtml,
|
|
166
|
+
"${searchIndex}": "[]",
|
|
167
|
+
"${title}": folderDisplayName,
|
|
168
|
+
"${meta}": "{}",
|
|
169
|
+
"${transformedMetadata}": "",
|
|
170
|
+
"${styleLink}": styleLink,
|
|
171
|
+
"${footer}": footer
|
|
172
|
+
};
|
|
173
|
+
for (const [key, value] of Object.entries(replacements)) {
|
|
174
|
+
finalHtml = finalHtml.replace(key, value);
|
|
175
|
+
}
|
|
176
|
+
// Add cache-busting timestamps to static file references
|
|
177
|
+
finalHtml = addTimestampToHtmlStaticRefs(finalHtml, cacheBustTimestamp);
|
|
178
|
+
|
|
179
|
+
await outputFile(indexPath, finalHtml);
|
|
180
|
+
generatedCount++;
|
|
181
|
+
progress.status('Auto-index', `Generated index.html for ${dir.replace(outputNorm, '') || '/'}`);
|
|
182
|
+
} catch (e) {
|
|
183
|
+
progress.log(`Error generating auto-index for ${dir}: ${e.message}`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (generatedCount > 0 || renamedCount > 0 || skippedHtmlCount > 0) {
|
|
189
|
+
let summary = `${generatedCount} generated, ${renamedCount} promoted`;
|
|
190
|
+
if (skippedHtmlCount > 0) {
|
|
191
|
+
summary += `, ${skippedHtmlCount} skipped (existing HTML)`;
|
|
192
|
+
}
|
|
193
|
+
progress.done('Auto-index', summary);
|
|
194
|
+
} else {
|
|
195
|
+
progress.log(`Auto-index: All folders already have index.html`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Batch processing helpers for build
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Process items in batches to limit memory usage
|
|
5
|
+
* @param {Array} items - Items to process
|
|
6
|
+
* @param {Function} processor - Async function to process each item
|
|
7
|
+
* @param {number} batchSize - Max concurrent operations
|
|
8
|
+
*/
|
|
9
|
+
export async function processBatched(items, processor, batchSize = 50) {
|
|
10
|
+
const results = [];
|
|
11
|
+
for (let i = 0; i < items.length; i += batchSize) {
|
|
12
|
+
const batch = items.slice(i, i + batchSize);
|
|
13
|
+
const batchResults = await Promise.all(batch.map(processor));
|
|
14
|
+
results.push(...batchResults);
|
|
15
|
+
// Allow GC to run between batches
|
|
16
|
+
if (global.gc) global.gc();
|
|
17
|
+
}
|
|
18
|
+
return results;
|
|
19
|
+
}
|