@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.
@@ -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.10.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
+ */
@@ -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
- export async function getAutomenu(source) {
5
- const tree = dirTree(source);
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
- /** order of menu items:
9
- * - Home
10
- * - Top-level folders A-Z
11
- * - Top-level files A-Z
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
- menuItems.push({
15
- path: "/",
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
- const topLevelItems = tree.children.sort(childSorter);
21
- const topLevelHtml = topLevelItems
22
- .map((item) => renderMenuItem({ ...item, source }))
23
- .join("");
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
- return `<ul>${topLevelHtml}</ul>`;
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 renderMenuItem({ path, name, children, source }) {
29
- const ext = extname(path);
30
- const href = path.replace(source, "").replace(ext, "");
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
- </li>`;
51
- return html;
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
- return {
54
- href,
55
- label,
56
- childMenuItems,
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
- * example output:
61
- * {
62
- * href:'Foo/Bar',
63
- * label:'Bar',
64
- * childMenuItems: [thisObject]
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) {