@kenjura/ursa 0.10.0 → 0.32.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 +157 -0
- package/README.md +182 -19
- package/bin/ursa.js +208 -0
- package/lib/index.js +7 -2
- package/meta/character-sheet-template.html +2 -0
- package/meta/default-template.html +29 -5
- package/meta/default.css +451 -115
- package/meta/menu.js +371 -0
- package/meta/search.js +208 -0
- package/meta/sectionify.js +36 -0
- package/meta/sticky.js +73 -0
- package/meta/toc-generator.js +124 -0
- package/meta/toc.js +93 -0
- package/package.json +25 -4
- package/src/helper/WikiImage.js +138 -0
- package/src/helper/automenu.js +211 -55
- package/src/helper/contentHash.js +71 -0
- package/src/helper/findStyleCss.js +26 -0
- package/src/helper/linkValidator.js +246 -0
- package/src/helper/metadataExtractor.js +19 -8
- package/src/helper/whitelistFilter.js +66 -0
- package/src/helper/wikitextHelper.js +6 -3
- package/src/jobs/generate.js +349 -109
- package/src/serve.js +138 -37
- package/.nvmrc +0 -1
- package/.vscode/launch.json +0 -20
- package/TODO.md +0 -16
- package/nodemon.json +0 -16
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// Table of Contents Generator
|
|
2
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
3
|
+
const tocNav = document.getElementById('nav-toc');
|
|
4
|
+
const article = document.querySelector('article#main-content');
|
|
5
|
+
|
|
6
|
+
if (!tocNav || !article) return;
|
|
7
|
+
|
|
8
|
+
// Find all headings in the article
|
|
9
|
+
const headings = article.querySelectorAll('h1, h2, h3');
|
|
10
|
+
|
|
11
|
+
if (headings.length === 0) {
|
|
12
|
+
tocNav.style.display = 'none';
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Generate TOC HTML
|
|
17
|
+
const tocList = document.createElement('ul');
|
|
18
|
+
|
|
19
|
+
headings.forEach((heading, index) => {
|
|
20
|
+
// Create unique ID for the heading if it doesn't have one
|
|
21
|
+
if (!heading.id) {
|
|
22
|
+
const text = heading.textContent.trim()
|
|
23
|
+
.toLowerCase()
|
|
24
|
+
.replace(/[^\w\s-]/g, '') // Remove special characters
|
|
25
|
+
.replace(/\s+/g, '-'); // Replace spaces with hyphens
|
|
26
|
+
heading.id = `heading-${index}-${text}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Create TOC item
|
|
30
|
+
const listItem = document.createElement('li');
|
|
31
|
+
listItem.className = `toc-${heading.tagName.toLowerCase()}`;
|
|
32
|
+
|
|
33
|
+
const link = document.createElement('a');
|
|
34
|
+
link.href = `#${heading.id}`;
|
|
35
|
+
link.textContent = heading.textContent;
|
|
36
|
+
link.addEventListener('click', handleTocClick);
|
|
37
|
+
|
|
38
|
+
listItem.appendChild(link);
|
|
39
|
+
tocList.appendChild(listItem);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
tocNav.appendChild(tocList);
|
|
43
|
+
|
|
44
|
+
// Handle TOC link clicks for smooth scrolling
|
|
45
|
+
function handleTocClick(e) {
|
|
46
|
+
e.preventDefault();
|
|
47
|
+
const targetId = e.target.getAttribute('href').substring(1);
|
|
48
|
+
const targetElement = document.getElementById(targetId);
|
|
49
|
+
|
|
50
|
+
if (targetElement) {
|
|
51
|
+
// Calculate offset to account for sticky header
|
|
52
|
+
const globalNavHeight = getComputedStyle(document.documentElement)
|
|
53
|
+
.getPropertyValue('--global-nav-height') || '48px';
|
|
54
|
+
const offset = parseInt(globalNavHeight) + 40; // Adjusted offset (was +20, now -29 for 49px less)
|
|
55
|
+
|
|
56
|
+
const targetPosition = targetElement.getBoundingClientRect().top + window.pageYOffset - offset;
|
|
57
|
+
|
|
58
|
+
window.scrollTo({
|
|
59
|
+
top: targetPosition,
|
|
60
|
+
behavior: 'smooth'
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Update active TOC item based on scroll position
|
|
66
|
+
function updateActiveTocItem() {
|
|
67
|
+
const scrollPosition = window.pageYOffset;
|
|
68
|
+
const globalNavHeight = getComputedStyle(document.documentElement)
|
|
69
|
+
.getPropertyValue('--global-nav-height') || '48px';
|
|
70
|
+
const offset = parseInt(globalNavHeight) + 100;
|
|
71
|
+
|
|
72
|
+
let activeHeading = null;
|
|
73
|
+
|
|
74
|
+
// Find the current heading based on scroll position
|
|
75
|
+
headings.forEach(heading => {
|
|
76
|
+
const headingTop = heading.getBoundingClientRect().top + window.pageYOffset;
|
|
77
|
+
if (headingTop <= scrollPosition + offset) {
|
|
78
|
+
activeHeading = heading;
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
updateTocActiveState(activeHeading);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Update TOC active state
|
|
86
|
+
function updateTocActiveState(activeHeading) {
|
|
87
|
+
const tocLinks = tocNav.querySelectorAll('a');
|
|
88
|
+
tocLinks.forEach(link => {
|
|
89
|
+
link.classList.remove('active');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
if (activeHeading) {
|
|
93
|
+
const activeLink = tocNav.querySelector(`a[href="#${activeHeading.id}"]`);
|
|
94
|
+
if (activeLink) {
|
|
95
|
+
activeLink.classList.add('active');
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Listen for heading stuck state changes from sticky.js
|
|
101
|
+
document.addEventListener('headingStuckStateChanged', (event) => {
|
|
102
|
+
if (event.detail.currentStuckHeading) {
|
|
103
|
+
updateTocActiveState(event.detail.currentStuckHeading);
|
|
104
|
+
} else {
|
|
105
|
+
// If no heading is stuck, fall back to scroll-based detection
|
|
106
|
+
updateActiveTocItem();
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// Listen for scroll events
|
|
111
|
+
let ticking = false;
|
|
112
|
+
window.addEventListener('scroll', () => {
|
|
113
|
+
if (!ticking) {
|
|
114
|
+
requestAnimationFrame(() => {
|
|
115
|
+
updateActiveTocItem();
|
|
116
|
+
ticking = false;
|
|
117
|
+
});
|
|
118
|
+
ticking = true;
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// Initial update
|
|
123
|
+
updateActiveTocItem();
|
|
124
|
+
});
|
package/meta/toc.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
(() => {
|
|
2
|
+
const toc = document.getElementById('toc');
|
|
3
|
+
if (!toc) return;
|
|
4
|
+
|
|
5
|
+
const links = Array.from(toc.querySelectorAll('a[href^="#"]'));
|
|
6
|
+
const heads = links
|
|
7
|
+
.map(a => document.getElementById(decodeURIComponent(a.hash.slice(1))))
|
|
8
|
+
.filter(Boolean);
|
|
9
|
+
|
|
10
|
+
const linkById = new Map(links.map(a => [decodeURIComponent(a.hash.slice(1)), a]));
|
|
11
|
+
|
|
12
|
+
// === sticky top detector ===
|
|
13
|
+
let STICKY_TOP = 48; // fallback
|
|
14
|
+
const nav = document.getElementById('nav-global');
|
|
15
|
+
|
|
16
|
+
function readStickyTop() {
|
|
17
|
+
const b = nav?.getBoundingClientRect();
|
|
18
|
+
// if nav is fixed at top, its bottom is the sticky line
|
|
19
|
+
STICKY_TOP = b ? Math.max(0, Math.round(b.bottom)) : 48;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
readStickyTop();
|
|
23
|
+
window.addEventListener('resize', readStickyTop, { passive: true });
|
|
24
|
+
if (nav && 'ResizeObserver' in window) {
|
|
25
|
+
new ResizeObserver(readStickyTop).observe(nav);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// === build 1px sentinels just above each heading ===
|
|
29
|
+
function ensureSentinels() {
|
|
30
|
+
heads.forEach(h => {
|
|
31
|
+
if (h.previousElementSibling?.classList.contains('toc-sentinel')) return;
|
|
32
|
+
const s = document.createElement('div');
|
|
33
|
+
s.className = 'toc-sentinel';
|
|
34
|
+
s.style.position = 'relative';
|
|
35
|
+
s.style.height = '1px';
|
|
36
|
+
s.style.marginTop = `-${STICKY_TOP}px`; // place sentinel STICKY_TOP above h
|
|
37
|
+
s.style.pointerEvents = 'none';
|
|
38
|
+
s.dataset.forId = h.id;
|
|
39
|
+
h.before(s);
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// === observer factory tied to current STICKY_TOP ===
|
|
44
|
+
let observer = null;
|
|
45
|
+
function (re)buildObserver() {
|
|
46
|
+
if (observer) observer.disconnect();
|
|
47
|
+
// ignore intersections near the bottom; we only care about the top line
|
|
48
|
+
const bottomRM = -(window.innerHeight - 1) + 'px';
|
|
49
|
+
observer = new IntersectionObserver(updateActiveFromSentinels, {
|
|
50
|
+
root: null,
|
|
51
|
+
rootMargin: `-${STICKY_TOP}px 0px ${bottomRM} 0px`,
|
|
52
|
+
threshold: 0
|
|
53
|
+
});
|
|
54
|
+
document.querySelectorAll('.toc-sentinel').forEach(s => observer.observe(s));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function updateActiveFromSentinels() {
|
|
58
|
+
// Pick the last sentinel whose top is <= 0 relative to the adjusted rootMargin,
|
|
59
|
+
// i.e. the heading whose sticky line has been crossed.
|
|
60
|
+
const sentinels = Array.from(document.querySelectorAll('.toc-sentinel'));
|
|
61
|
+
let candidate = null;
|
|
62
|
+
for (const s of sentinels) {
|
|
63
|
+
const top = s.getBoundingClientRect().top - STICKY_TOP; // compare to sticky line
|
|
64
|
+
if (top <= 0) candidate = s; else break;
|
|
65
|
+
}
|
|
66
|
+
const activeId = candidate ? candidate.dataset.forId : heads[0]?.id;
|
|
67
|
+
links.forEach(a => a.classList.toggle('active', decodeURIComponent(a.hash.slice(1)) === activeId));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// keep anchor jumps clear of the sticky bar
|
|
71
|
+
heads.forEach(h => h.style.scrollMarginTop = (STICKY_TOP + 8) + 'px');
|
|
72
|
+
|
|
73
|
+
// initial setup
|
|
74
|
+
ensureSentinels();
|
|
75
|
+
(re)buildObserver();
|
|
76
|
+
updateActiveFromSentinels();
|
|
77
|
+
|
|
78
|
+
// react when sticky top changes
|
|
79
|
+
let resizeTick = false;
|
|
80
|
+
window.addEventListener('resize', () => {
|
|
81
|
+
if (resizeTick) return;
|
|
82
|
+
resizeTick = true;
|
|
83
|
+
requestAnimationFrame(() => {
|
|
84
|
+
resizeTick = false;
|
|
85
|
+
readStickyTop();
|
|
86
|
+
// update sentinel offsets
|
|
87
|
+
document.querySelectorAll('.toc-sentinel').forEach(s => s.style.marginTop = `-${STICKY_TOP}px`);
|
|
88
|
+
heads.forEach(h => h.style.scrollMarginTop = (STICKY_TOP + 8) + 'px');
|
|
89
|
+
(re)buildObserver();
|
|
90
|
+
updateActiveFromSentinels();
|
|
91
|
+
});
|
|
92
|
+
}, { passive: true });
|
|
93
|
+
})();
|
package/package.json
CHANGED
|
@@ -2,12 +2,17 @@
|
|
|
2
2
|
"name": "@kenjura/ursa",
|
|
3
3
|
"author": "Andrew London <andrew@kenjura.com>",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"version": "0.
|
|
5
|
+
"version": "0.32.0",
|
|
6
6
|
"description": "static site generator from MD/wikitext/YML",
|
|
7
7
|
"main": "lib/index.js",
|
|
8
|
+
"bin": {
|
|
9
|
+
"ursa": "bin/ursa.js"
|
|
10
|
+
},
|
|
8
11
|
"scripts": {
|
|
9
12
|
"serve": "nodemon --config nodemon.json src/serve.js",
|
|
10
13
|
"serve:debug": "nodemon --config nodemon.json --inspect-brk src/serve.js",
|
|
14
|
+
"cli:debug": "node --inspect bin/ursa.js",
|
|
15
|
+
"cli:debug-brk": "node --inspect-brk bin/ursa.js",
|
|
11
16
|
"start": "node src/index.js",
|
|
12
17
|
"test": "node --experimental-vm-modules node_modules/jest/bin/jest.js"
|
|
13
18
|
},
|
|
@@ -27,7 +32,8 @@
|
|
|
27
32
|
"markdown-it-sup": "^1.0.0",
|
|
28
33
|
"node-watch": "^0.7.3",
|
|
29
34
|
"object-to-xml": "^2.0.0",
|
|
30
|
-
"yaml": "^2.1.3"
|
|
35
|
+
"yaml": "^2.1.3",
|
|
36
|
+
"yargs": "^17.7.2"
|
|
31
37
|
},
|
|
32
38
|
"devDependencies": {
|
|
33
39
|
"jest": "^29.6.2",
|
|
@@ -43,6 +49,21 @@
|
|
|
43
49
|
},
|
|
44
50
|
"keywords": [
|
|
45
51
|
"static-site",
|
|
46
|
-
"markdown"
|
|
47
|
-
|
|
52
|
+
"markdown",
|
|
53
|
+
"static-site-generator",
|
|
54
|
+
"wikitext",
|
|
55
|
+
"cli"
|
|
56
|
+
],
|
|
57
|
+
"files": [
|
|
58
|
+
"lib/",
|
|
59
|
+
"src/",
|
|
60
|
+
"bin/",
|
|
61
|
+
"meta/",
|
|
62
|
+
"README.md",
|
|
63
|
+
"CHANGELOG.md"
|
|
64
|
+
],
|
|
65
|
+
"publishConfig": {
|
|
66
|
+
"access": "public",
|
|
67
|
+
"registry": "https://registry.npmjs.org/"
|
|
68
|
+
}
|
|
48
69
|
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
export function getImageTag(args) {
|
|
2
|
+
if (!args) args = {};
|
|
3
|
+
if (!args.name) { console.error('WikiImage.getImage > cannot get image with no name.'); return ''; }
|
|
4
|
+
// if (!args.article) { console.error('WikiImage.getImage > article not supplied.'); return ''; }
|
|
5
|
+
|
|
6
|
+
// var images = args.article.images;
|
|
7
|
+
// var image = null;
|
|
8
|
+
// if (images) {
|
|
9
|
+
// for (var i = 0; i < images.length; i++) {
|
|
10
|
+
// if ( images[i].name == args.name ) {
|
|
11
|
+
// image = images[i];
|
|
12
|
+
// break;
|
|
13
|
+
// }
|
|
14
|
+
// }
|
|
15
|
+
// }
|
|
16
|
+
// if (!image) {
|
|
17
|
+
// // image not yet uploaded
|
|
18
|
+
// return '<div class="noImage" ng-click="activateImage(\''+args.name+'\')">?</div>';
|
|
19
|
+
// } else {
|
|
20
|
+
// path
|
|
21
|
+
// var imgUrl = WikiImage.imageRoot + image.path;
|
|
22
|
+
var imgUrl = args.imgUrl;
|
|
23
|
+
// style
|
|
24
|
+
var width = 'auto', height = 'auto', classes = '', caption = '', fillMode = null, float = '';
|
|
25
|
+
if (args.args&&args.args.length) {
|
|
26
|
+
for (var i = 0; i < args.args.length; i++) {
|
|
27
|
+
var arg = args.args[i];
|
|
28
|
+
// numbers = width/height. for now, let's just do width
|
|
29
|
+
if (!isNaN(arg)) {
|
|
30
|
+
if (width=='auto') width = parseFloat(arg) + 'px';
|
|
31
|
+
else height = parseFloat(arg) + 'px';
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (arg.substr(-1)=='%') {
|
|
35
|
+
if (width=='auto') width = arg;
|
|
36
|
+
else height = arg;
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
if (arg.substr(-2)=='px') {
|
|
40
|
+
if (width=='auto') width = arg;
|
|
41
|
+
else height = arg;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
// string values might mean something...
|
|
45
|
+
if (arg=='right') { float = 'float: right; clear: right;'; continue; }
|
|
46
|
+
if (arg=='fit'||arg=='box') { classes += arg + ' '; continue; }
|
|
47
|
+
if (arg=='center') { classes += 'center '; continue; }
|
|
48
|
+
if (arg=='cover'||arg=='contain') { fillMode = arg; continue; }
|
|
49
|
+
// else, assume it's the caption
|
|
50
|
+
caption = arg;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
var style = 'width: '+width+'; height: '+height+';' + float;
|
|
54
|
+
|
|
55
|
+
// events
|
|
56
|
+
// var events = 'onclick="_scope.activateImage(\''+args.name+'\',\''+imgUrl+'\')"';
|
|
57
|
+
var events = 'ng-click="activateImage(\''+args.name+'\',\''+imgUrl+'\')"';
|
|
58
|
+
|
|
59
|
+
//return '<img class="wikiImage" src="'+imgUrl+'" style="'+style+'" '+events+' />';
|
|
60
|
+
|
|
61
|
+
var template = ''+
|
|
62
|
+
'<a href="{src}">'+
|
|
63
|
+
'<div class="wikiImage {class}" style="{style}" title="{caption}">'+
|
|
64
|
+
'<img src="{src}" {events} />'+
|
|
65
|
+
'<div class="wikiImage_caption">{caption}</div>'+
|
|
66
|
+
'</div>'+
|
|
67
|
+
'</a>';
|
|
68
|
+
|
|
69
|
+
// var template2 = ''+
|
|
70
|
+
// '<a href="{src}">'+
|
|
71
|
+
// '<div class="wikiImage {class}" style="background: url(\'{src}\'); background-size: {fillMode}; {style}" {events}>'+
|
|
72
|
+
// '<div class="wikiImage_caption">{caption}</div>'+
|
|
73
|
+
// '</div>'+
|
|
74
|
+
// '</a>';
|
|
75
|
+
|
|
76
|
+
const template2 = `<span class="wiki-image-container"><img src="${imgUrl}" style="${style}" class="wikiImage wiki-image" onClick="evt => imageZoom(evt)" />`;
|
|
77
|
+
return template2;
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
var html = template;
|
|
81
|
+
if (fillMode) html = template2;
|
|
82
|
+
|
|
83
|
+
html = html.replace( /\{src\}/g , imgUrl );
|
|
84
|
+
html = html.replace( '{class}' , classes );
|
|
85
|
+
html = html.replace( '{style}' , style );
|
|
86
|
+
html = html.replace( '{events}' , events );
|
|
87
|
+
html = html.replace( /\{caption\}/g , caption );
|
|
88
|
+
if (fillMode)
|
|
89
|
+
html = html.replace( '{fillMode}' , fillMode );
|
|
90
|
+
|
|
91
|
+
return html;
|
|
92
|
+
// }
|
|
93
|
+
|
|
94
|
+
/*
|
|
95
|
+
var imgCache = localStorage.getItem('imageCache');
|
|
96
|
+
if (!imgCache) imgCache = JSON.stringify({});
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
imgCache = JSON.parse(imgCache);
|
|
100
|
+
var imgUrl = imgCache[args.name];
|
|
101
|
+
if (!imgUrl) {
|
|
102
|
+
//var img = new WikiImage(args);
|
|
103
|
+
var img = new WikiImage(args);
|
|
104
|
+
var tag = img.render();
|
|
105
|
+
imgCache[args.name] = img.url;
|
|
106
|
+
} else {
|
|
107
|
+
args.url = imgUrl;
|
|
108
|
+
var img = new WikiImage(args);
|
|
109
|
+
var tag = img.render();
|
|
110
|
+
}
|
|
111
|
+
localStorage.setItem('imageCache',JSON.stringify(imgCache));
|
|
112
|
+
return tag;
|
|
113
|
+
} catch(e) {
|
|
114
|
+
error('WikiImage.getImage > could not retrieve or parse the image cache. error to follow.');
|
|
115
|
+
error(e);
|
|
116
|
+
return new WikiImage(args);
|
|
117
|
+
}
|
|
118
|
+
*/
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
/* how images should work:
|
|
127
|
+
* when you load an article, you also get a list of all images used in that article, and their URLs
|
|
128
|
+
* when rendering an image, check that list first. if the image name isn't in that list, don't bother trying to load the image
|
|
129
|
+
** if it is in the list, load it, relying on browser cache to reduce load
|
|
130
|
+
** important: do not use the server method to find each image's url; get them all from the loadArticle call
|
|
131
|
+
** the FS db option should store image (and link) data in a special section at the end of the article body. it also doesn't allow images to have a different name than their url
|
|
132
|
+
* when an image is not yet uploaded, show a gray box with a question mark
|
|
133
|
+
* when the grey box or the image is clicked, pop up the image upload modal
|
|
134
|
+
* the image upload modal uses the appropriate service endpoint to upload, then returns the new URL
|
|
135
|
+
** the new URL is then cached
|
|
136
|
+
** the image is loaded from the URL
|
|
137
|
+
** the image is associated with the article, which is immediately saved with the new image association
|
|
138
|
+
*/
|
package/src/helper/automenu.js
CHANGED
|
@@ -1,69 +1,225 @@
|
|
|
1
1
|
import dirTree from "directory-tree";
|
|
2
|
-
import { extname, basename } from "path";
|
|
2
|
+
import { extname, basename, join, dirname } from "path";
|
|
3
|
+
import { existsSync } from "fs";
|
|
3
4
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
const menuItems = [];
|
|
5
|
+
// Icon extensions to check for custom icons
|
|
6
|
+
const ICON_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.ico'];
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
**/
|
|
8
|
+
// Default icons (using emoji for simplicity, can be replaced with SVG)
|
|
9
|
+
const FOLDER_ICON = '📁';
|
|
10
|
+
const DOCUMENT_ICON = '📄';
|
|
11
|
+
const HOME_ICON = '🏠';
|
|
13
12
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
name: "Home",
|
|
17
|
-
type: "file",
|
|
18
|
-
});
|
|
13
|
+
// Index file extensions to check for folder links
|
|
14
|
+
const INDEX_EXTENSIONS = ['.md', '.txt', '.yml', '.yaml'];
|
|
19
15
|
|
|
20
|
-
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
16
|
+
function hasIndexFile(dirPath) {
|
|
17
|
+
for (const ext of INDEX_EXTENSIONS) {
|
|
18
|
+
const indexPath = join(dirPath, `index${ext}`);
|
|
19
|
+
if (existsSync(indexPath)) {
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
24
25
|
|
|
25
|
-
|
|
26
|
+
function findCustomIcon(dirPath, source) {
|
|
27
|
+
for (const ext of ICON_EXTENSIONS) {
|
|
28
|
+
const iconPath = join(dirPath, `icon${ext}`);
|
|
29
|
+
if (existsSync(iconPath)) {
|
|
30
|
+
// Return the web-accessible path
|
|
31
|
+
return iconPath.replace(source, '/');
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
26
35
|
}
|
|
27
36
|
|
|
28
|
-
function
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
const label = basename(path, ext);
|
|
32
|
-
// const childMenuItems = Array.isArray(children)
|
|
33
|
-
// ? children
|
|
34
|
-
// .map((child) => renderMenuItem({ ...child, source }))
|
|
35
|
-
// .sort(menuItemSorter)
|
|
36
|
-
// : null;
|
|
37
|
-
const html = `
|
|
38
|
-
<li data-has-children="${!!children}">
|
|
39
|
-
<a href="/${href}">${label}</a>
|
|
40
|
-
${
|
|
41
|
-
children
|
|
42
|
-
? `<ul>
|
|
43
|
-
${children
|
|
44
|
-
.sort(childSorter)
|
|
45
|
-
.map((child) => renderMenuItem({ ...child, source }))
|
|
46
|
-
.join("")}
|
|
47
|
-
</ul>`
|
|
48
|
-
: ""
|
|
37
|
+
function getIcon(item, source, isHome = false) {
|
|
38
|
+
if (isHome) {
|
|
39
|
+
return `<span class="menu-icon">${HOME_ICON}</span>`;
|
|
49
40
|
}
|
|
50
|
-
|
|
51
|
-
|
|
41
|
+
|
|
42
|
+
if (item.children) {
|
|
43
|
+
// It's a folder - check for custom icon
|
|
44
|
+
const customIcon = findCustomIcon(item.path, source);
|
|
45
|
+
if (customIcon) {
|
|
46
|
+
return `<span class="menu-icon"><img src="${customIcon}" alt="" /></span>`;
|
|
47
|
+
}
|
|
48
|
+
return `<span class="menu-icon">${FOLDER_ICON}</span>`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// It's a file - check for custom icon in parent directory with matching name
|
|
52
|
+
const dir = dirname(item.path);
|
|
53
|
+
const base = basename(item.path, extname(item.path));
|
|
54
|
+
for (const ext of ICON_EXTENSIONS) {
|
|
55
|
+
const iconPath = join(dir, `${base}-icon${ext}`);
|
|
56
|
+
if (existsSync(iconPath)) {
|
|
57
|
+
return `<span class="menu-icon"><img src="${iconPath.replace(source, '/')}" alt="" /></span>`;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return `<span class="menu-icon">${DOCUMENT_ICON}</span>`;
|
|
62
|
+
}
|
|
52
63
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
64
|
+
/**
|
|
65
|
+
* Resolve an href to a valid .html file path, checking against validPaths.
|
|
66
|
+
* Returns { href, inactive, debug } where inactive is true if the link doesn't resolve to a valid path.
|
|
67
|
+
*
|
|
68
|
+
* Logic:
|
|
69
|
+
* - "/" -> "/index.html"
|
|
70
|
+
* - Any link lacking an extension:
|
|
71
|
+
* - Try adding ".html" - if path exists, use it
|
|
72
|
+
* - Try adding "/index.html" - if path exists, use it
|
|
73
|
+
* - Otherwise, mark as inactive
|
|
74
|
+
* - Links with extensions are checked directly
|
|
75
|
+
*/
|
|
76
|
+
function resolveHref(rawHref, validPaths) {
|
|
77
|
+
const debugTries = [];
|
|
78
|
+
|
|
79
|
+
if (!rawHref) {
|
|
80
|
+
return { href: null, inactive: false, debug: 'null href' };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Normalize for checking (lowercase)
|
|
84
|
+
const normalize = (path) => path.toLowerCase();
|
|
85
|
+
|
|
86
|
+
// Root link
|
|
87
|
+
if (rawHref === '/') {
|
|
88
|
+
const indexPath = '/index.html';
|
|
89
|
+
const exists = validPaths.has(normalize(indexPath));
|
|
90
|
+
debugTries.push(`${indexPath} → ${exists ? '✓' : '✗'}`);
|
|
91
|
+
if (exists) {
|
|
92
|
+
return { href: '/index.html', inactive: false, debug: debugTries.join(' | ') };
|
|
93
|
+
}
|
|
94
|
+
return { href: '/', inactive: true, debug: debugTries.join(' | ') };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Check if the link already has an extension
|
|
98
|
+
const ext = extname(rawHref);
|
|
99
|
+
if (ext) {
|
|
100
|
+
// Has extension - check if path exists
|
|
101
|
+
const exists = validPaths.has(normalize(rawHref));
|
|
102
|
+
debugTries.push(`${rawHref} → ${exists ? '✓' : '✗'}`);
|
|
103
|
+
return { href: rawHref, inactive: !exists, debug: debugTries.join(' | ') };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// No extension - try .html first
|
|
107
|
+
const htmlPath = rawHref + '.html';
|
|
108
|
+
const htmlExists = validPaths.has(normalize(htmlPath));
|
|
109
|
+
debugTries.push(`${htmlPath} → ${htmlExists ? '✓' : '✗'}`);
|
|
110
|
+
if (htmlExists) {
|
|
111
|
+
return { href: htmlPath, inactive: false, debug: debugTries.join(' | ') };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Try /index.html
|
|
115
|
+
const indexPath = rawHref + '/index.html';
|
|
116
|
+
const indexExists = validPaths.has(normalize(indexPath));
|
|
117
|
+
debugTries.push(`${indexPath} → ${indexExists ? '✓' : '✗'}`);
|
|
118
|
+
if (indexExists) {
|
|
119
|
+
return { href: indexPath, inactive: false, debug: debugTries.join(' | ') };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Neither exists - mark as inactive, keep original href
|
|
123
|
+
return { href: rawHref, inactive: true, debug: debugTries.join(' | ') };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Build a flat tree structure with path info for JS navigation
|
|
127
|
+
function buildMenuData(tree, source, validPaths, parentPath = '') {
|
|
128
|
+
const items = [];
|
|
129
|
+
|
|
130
|
+
for (const item of tree.children || []) {
|
|
131
|
+
const ext = extname(item.path);
|
|
132
|
+
const label = basename(item.path, ext);
|
|
133
|
+
const hasChildren = !!item.children;
|
|
134
|
+
const relativePath = item.path.replace(source, '');
|
|
135
|
+
const folderPath = parentPath ? `${parentPath}/${label}` : label;
|
|
136
|
+
|
|
137
|
+
let rawHref = null;
|
|
138
|
+
if (hasChildren) {
|
|
139
|
+
if (hasIndexFile(item.path)) {
|
|
140
|
+
rawHref = `/${relativePath}/index.html`;
|
|
141
|
+
}
|
|
142
|
+
} else {
|
|
143
|
+
rawHref = `/${relativePath.replace(ext, '')}`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Resolve the href and check if target exists
|
|
147
|
+
const { href, inactive, debug } = resolveHref(rawHref, validPaths);
|
|
148
|
+
|
|
149
|
+
const menuItem = {
|
|
150
|
+
label,
|
|
151
|
+
path: folderPath,
|
|
152
|
+
href,
|
|
153
|
+
inactive,
|
|
154
|
+
debug,
|
|
155
|
+
hasChildren,
|
|
156
|
+
icon: getIcon(item, source),
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
if (hasChildren) {
|
|
160
|
+
menuItem.children = buildMenuData(item, source, validPaths, folderPath);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
items.push(menuItem);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return items.sort((a, b) => {
|
|
167
|
+
if (a.hasChildren && !b.hasChildren) return -1;
|
|
168
|
+
if (b.hasChildren && !a.hasChildren) return 1;
|
|
169
|
+
if (a.label > b.label) return 1;
|
|
170
|
+
if (a.label < b.label) return -1;
|
|
171
|
+
return 0;
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export async function getAutomenu(source, validPaths) {
|
|
176
|
+
const tree = dirTree(source);
|
|
177
|
+
const menuData = buildMenuData(tree, source, validPaths);
|
|
178
|
+
|
|
179
|
+
// Add home item with resolved href
|
|
180
|
+
const homeResolved = resolveHref('/', validPaths);
|
|
181
|
+
const fullMenuData = [
|
|
182
|
+
{ label: 'Home', path: '', href: homeResolved.href, inactive: homeResolved.inactive, debug: homeResolved.debug, hasChildren: false, icon: `<span class="menu-icon">${HOME_ICON}</span>` },
|
|
183
|
+
...menuData
|
|
184
|
+
];
|
|
185
|
+
|
|
186
|
+
// Embed the menu data as JSON for JavaScript to use
|
|
187
|
+
const menuDataScript = `<script type="application/json" id="menu-data">${JSON.stringify(fullMenuData)}</script>`;
|
|
188
|
+
|
|
189
|
+
// Render the breadcrumb header (hidden by default, shown when navigating)
|
|
190
|
+
const breadcrumbHtml = `
|
|
191
|
+
<div class="menu-breadcrumb" style="display: none;">
|
|
192
|
+
<button class="menu-back" title="Go back">←</button>
|
|
193
|
+
<button class="menu-home" title="Go to root">🏠</button>
|
|
194
|
+
<span class="menu-current-path"></span>
|
|
195
|
+
</div>`;
|
|
58
196
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
197
|
+
// Render the initial menu (root level)
|
|
198
|
+
const menuHtml = renderMenuLevel(fullMenuData, 0);
|
|
199
|
+
|
|
200
|
+
return `${menuDataScript}${breadcrumbHtml}<ul class="menu-level" data-level="0">${menuHtml}</ul>`;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function renderMenuLevel(items, level) {
|
|
204
|
+
return items.map(item => {
|
|
205
|
+
const hasChildrenClass = item.hasChildren ? ' has-children' : '';
|
|
206
|
+
const hasChildrenIndicator = item.hasChildren ? '<span class="menu-more">⋯</span>' : '';
|
|
207
|
+
const inactiveClass = item.inactive ? ' inactive' : '';
|
|
208
|
+
const debugText = item.debug ? ` [DEBUG: ${item.debug}]` : '';
|
|
209
|
+
|
|
210
|
+
const labelHtml = item.href
|
|
211
|
+
? `<a href="${item.href}" class="menu-label${inactiveClass}">${item.label}${debugText}</a>`
|
|
212
|
+
: `<span class="menu-label">${item.label}</span>`;
|
|
213
|
+
|
|
214
|
+
return `
|
|
215
|
+
<li class="menu-item${hasChildrenClass}" data-path="${item.path}">
|
|
216
|
+
<div class="menu-item-row">
|
|
217
|
+
${item.icon}
|
|
218
|
+
${labelHtml}
|
|
219
|
+
${hasChildrenIndicator}
|
|
220
|
+
</div>
|
|
221
|
+
</li>`;
|
|
222
|
+
}).join('');
|
|
67
223
|
}
|
|
68
224
|
|
|
69
225
|
function childSorter(a, b) {
|