@kenjura/ursa 0.72.0 → 0.75.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 +19 -0
- package/meta/default-template.html +32 -6
- package/meta/default.css +365 -175
- package/meta/menu.js +153 -80
- package/meta/search.js +7 -13
- package/meta/sectionify.js +10 -0
- package/meta/toc-generator.js +12 -6
- package/meta/widgets.js +376 -0
- package/package.json +1 -1
- package/src/dev.js +39 -11
- package/src/helper/automenu.js +102 -12
- package/src/helper/breadcrumbs.js +42 -0
- package/src/helper/build/autoIndex.js +80 -23
- package/src/helper/build/menu.js +4 -4
- package/src/helper/customMenu.js +118 -29
- package/src/helper/imageProcessor.js +38 -8
- package/src/jobs/generate.js +52 -9
package/meta/widgets.js
ADDED
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Widget system for the top nav right-side panel.
|
|
3
|
+
*
|
|
4
|
+
* Widgets appear as icon buttons in the nav bar. Clicking a button toggles a
|
|
5
|
+
* dropdown panel anchored to the right side below the nav. Only one widget can
|
|
6
|
+
* be open at a time.
|
|
7
|
+
*
|
|
8
|
+
* Built-in widgets: TOC, Search, Profile
|
|
9
|
+
*/
|
|
10
|
+
class WidgetManager {
|
|
11
|
+
constructor() {
|
|
12
|
+
this.dropdown = document.getElementById('widget-dropdown');
|
|
13
|
+
this.buttons = document.querySelectorAll('.widget-button[data-widget]');
|
|
14
|
+
this.activeWidget = null;
|
|
15
|
+
|
|
16
|
+
if (!this.dropdown || this.buttons.length === 0) return;
|
|
17
|
+
|
|
18
|
+
this.init();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
init() {
|
|
22
|
+
// Bind button clicks
|
|
23
|
+
this.buttons.forEach(btn => {
|
|
24
|
+
btn.addEventListener('click', (e) => {
|
|
25
|
+
e.stopPropagation();
|
|
26
|
+
const widgetName = btn.dataset.widget;
|
|
27
|
+
this.toggle(widgetName);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// Close on outside click
|
|
32
|
+
document.addEventListener('click', (e) => {
|
|
33
|
+
if (this.activeWidget &&
|
|
34
|
+
!this.dropdown.contains(e.target) &&
|
|
35
|
+
!e.target.closest('.widget-button')) {
|
|
36
|
+
this.close();
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// Close on Escape
|
|
41
|
+
document.addEventListener('keydown', (e) => {
|
|
42
|
+
if (e.key === 'Escape' && this.activeWidget) {
|
|
43
|
+
this.close();
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// Initialize search widget content
|
|
48
|
+
this.initSearchWidget();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Toggle a widget open/closed. If a different widget is open, switch to the new one.
|
|
53
|
+
*/
|
|
54
|
+
toggle(widgetName) {
|
|
55
|
+
if (this.activeWidget === widgetName) {
|
|
56
|
+
this.close();
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
this.open(widgetName);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Open a specific widget panel.
|
|
65
|
+
*/
|
|
66
|
+
open(widgetName) {
|
|
67
|
+
// Close any open widget first
|
|
68
|
+
if (this.activeWidget) {
|
|
69
|
+
this.deactivateContent(this.activeWidget);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
this.activeWidget = widgetName;
|
|
73
|
+
|
|
74
|
+
// Show dropdown
|
|
75
|
+
this.dropdown.classList.remove('hidden');
|
|
76
|
+
this.dropdown.dataset.activeWidget = widgetName;
|
|
77
|
+
|
|
78
|
+
// Show the correct content panel
|
|
79
|
+
this.activateContent(widgetName);
|
|
80
|
+
|
|
81
|
+
// Update button states
|
|
82
|
+
this.buttons.forEach(btn => {
|
|
83
|
+
btn.classList.toggle('active', btn.dataset.widget === widgetName);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// Fire event for other scripts to listen to
|
|
87
|
+
document.dispatchEvent(new CustomEvent('widget-opened', { detail: { widget: widgetName } }));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Close the currently open widget.
|
|
92
|
+
*/
|
|
93
|
+
close() {
|
|
94
|
+
if (!this.activeWidget) return;
|
|
95
|
+
|
|
96
|
+
const closing = this.activeWidget;
|
|
97
|
+
this.deactivateContent(closing);
|
|
98
|
+
|
|
99
|
+
this.activeWidget = null;
|
|
100
|
+
this.dropdown.classList.add('hidden');
|
|
101
|
+
delete this.dropdown.dataset.activeWidget;
|
|
102
|
+
|
|
103
|
+
// Update button states
|
|
104
|
+
this.buttons.forEach(btn => btn.classList.remove('active'));
|
|
105
|
+
|
|
106
|
+
// Fire event
|
|
107
|
+
document.dispatchEvent(new CustomEvent('widget-closed', { detail: { widget: closing } }));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Show a widget's content panel.
|
|
112
|
+
*/
|
|
113
|
+
activateContent(widgetName) {
|
|
114
|
+
const content = this.dropdown.querySelector(`.widget-content[data-widget="${widgetName}"]`);
|
|
115
|
+
if (content) {
|
|
116
|
+
content.classList.add('active');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Widget-specific activation
|
|
120
|
+
if (widgetName === 'search') {
|
|
121
|
+
this.activateSearch();
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Hide a widget's content panel.
|
|
127
|
+
*/
|
|
128
|
+
deactivateContent(widgetName) {
|
|
129
|
+
const content = this.dropdown.querySelector(`.widget-content[data-widget="${widgetName}"]`);
|
|
130
|
+
if (content) {
|
|
131
|
+
content.classList.remove('active');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Widget-specific deactivation
|
|
135
|
+
if (widgetName === 'search') {
|
|
136
|
+
this.deactivateSearch();
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Initialize search widget — move the search input and results into the widget panel.
|
|
142
|
+
*/
|
|
143
|
+
initSearchWidget() {
|
|
144
|
+
const searchContent = document.getElementById('widget-content-search');
|
|
145
|
+
if (!searchContent) return;
|
|
146
|
+
|
|
147
|
+
// The search input and wrapper are created by search.js (GlobalSearch).
|
|
148
|
+
// We need to wait for it to be ready, then move elements into the widget.
|
|
149
|
+
// Use a short delay to let GlobalSearch initialize first.
|
|
150
|
+
const moveSearch = () => {
|
|
151
|
+
const searchWrapper = document.querySelector('.search-wrapper-inline');
|
|
152
|
+
const searchResults = document.getElementById('search-results');
|
|
153
|
+
|
|
154
|
+
if (searchWrapper) {
|
|
155
|
+
// Clone the search input into the widget (the inline one stays for non-top-menu/mobile)
|
|
156
|
+
// Actually, we'll relocate the existing elements when the widget is activated.
|
|
157
|
+
// For now, create a dedicated search input for the widget.
|
|
158
|
+
const widgetInput = document.createElement('input');
|
|
159
|
+
widgetInput.id = 'widget-search-input';
|
|
160
|
+
widgetInput.type = 'text';
|
|
161
|
+
widgetInput.placeholder = 'Search...';
|
|
162
|
+
widgetInput.className = 'widget-search-input';
|
|
163
|
+
|
|
164
|
+
const widgetWrapper = document.createElement('div');
|
|
165
|
+
widgetWrapper.className = 'widget-search-wrapper';
|
|
166
|
+
widgetWrapper.appendChild(widgetInput);
|
|
167
|
+
|
|
168
|
+
// Create dedicated results container for widget
|
|
169
|
+
const widgetResults = document.createElement('div');
|
|
170
|
+
widgetResults.id = 'widget-search-results';
|
|
171
|
+
widgetResults.className = 'widget-search-results';
|
|
172
|
+
|
|
173
|
+
searchContent.appendChild(widgetWrapper);
|
|
174
|
+
searchContent.appendChild(widgetResults);
|
|
175
|
+
|
|
176
|
+
// Bind the widget search input to the GlobalSearch instance
|
|
177
|
+
this.bindWidgetSearch(widgetInput, widgetResults);
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
// Wait for search.js to initialize
|
|
182
|
+
setTimeout(moveSearch, 50);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Bind the widget search input to use GlobalSearch's search functionality.
|
|
187
|
+
*/
|
|
188
|
+
bindWidgetSearch(input, resultsContainer) {
|
|
189
|
+
this._widgetSearchInput = input;
|
|
190
|
+
this._widgetSearchResults = resultsContainer;
|
|
191
|
+
|
|
192
|
+
let currentSelection = -1;
|
|
193
|
+
|
|
194
|
+
input.addEventListener('input', () => {
|
|
195
|
+
const query = input.value.trim();
|
|
196
|
+
this.performWidgetSearch(query);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
input.addEventListener('keydown', (e) => {
|
|
200
|
+
const items = resultsContainer.querySelectorAll('.search-result-item');
|
|
201
|
+
|
|
202
|
+
switch (e.key) {
|
|
203
|
+
case 'ArrowDown':
|
|
204
|
+
e.preventDefault();
|
|
205
|
+
if (items.length > 0) {
|
|
206
|
+
currentSelection = Math.min(currentSelection + 1, items.length - 1);
|
|
207
|
+
this.updateWidgetSearchSelection(items, currentSelection);
|
|
208
|
+
}
|
|
209
|
+
break;
|
|
210
|
+
case 'ArrowUp':
|
|
211
|
+
e.preventDefault();
|
|
212
|
+
if (items.length > 0) {
|
|
213
|
+
currentSelection = Math.max(currentSelection - 1, 0);
|
|
214
|
+
this.updateWidgetSearchSelection(items, currentSelection);
|
|
215
|
+
}
|
|
216
|
+
break;
|
|
217
|
+
case 'Enter':
|
|
218
|
+
e.preventDefault();
|
|
219
|
+
if (currentSelection >= 0 && items[currentSelection]) {
|
|
220
|
+
items[currentSelection].click();
|
|
221
|
+
}
|
|
222
|
+
break;
|
|
223
|
+
case 'Escape':
|
|
224
|
+
this.close();
|
|
225
|
+
break;
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
// Reset selection on new search
|
|
230
|
+
input.addEventListener('input', () => { currentSelection = -1; });
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Perform search using GlobalSearch's search logic, rendering into widget results.
|
|
235
|
+
*/
|
|
236
|
+
performWidgetSearch(query) {
|
|
237
|
+
const gs = window.globalSearch;
|
|
238
|
+
const container = this._widgetSearchResults;
|
|
239
|
+
if (!gs || !container) return;
|
|
240
|
+
|
|
241
|
+
container.innerHTML = '';
|
|
242
|
+
|
|
243
|
+
if (!query || query.length < gs.MIN_QUERY_LENGTH) {
|
|
244
|
+
if (query && query.length > 0) {
|
|
245
|
+
container.innerHTML = `<div class="search-result-message">Type at least ${gs.MIN_QUERY_LENGTH} characters to search</div>`;
|
|
246
|
+
}
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (!gs.indexLoaded) {
|
|
251
|
+
container.innerHTML = '<div class="search-result-message">Loading search index...</div>';
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const pathResults = gs.searchPaths(query);
|
|
256
|
+
const fullTextResults = gs.searchFullText(query);
|
|
257
|
+
|
|
258
|
+
// Deduplicate
|
|
259
|
+
const pathPaths = new Set(pathResults.map(r => r.path));
|
|
260
|
+
const uniqueFullTextResults = fullTextResults.filter(r => !pathPaths.has(r.path));
|
|
261
|
+
|
|
262
|
+
if (pathResults.length === 0 && uniqueFullTextResults.length === 0) {
|
|
263
|
+
container.innerHTML = `<div class="search-result-message">No results for "${query}"</div>`;
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Path results section
|
|
268
|
+
if (pathResults.length > 0) {
|
|
269
|
+
const section = document.createElement('div');
|
|
270
|
+
section.className = 'search-section';
|
|
271
|
+
const header = document.createElement('div');
|
|
272
|
+
header.className = 'search-section-header';
|
|
273
|
+
header.textContent = `Title/Path Matches (${pathResults.length})`;
|
|
274
|
+
section.appendChild(header);
|
|
275
|
+
|
|
276
|
+
const limit = Math.min(pathResults.length, 10);
|
|
277
|
+
for (let i = 0; i < limit; i++) {
|
|
278
|
+
section.appendChild(this.createWidgetResultItem(pathResults[i]));
|
|
279
|
+
}
|
|
280
|
+
if (pathResults.length > 10) {
|
|
281
|
+
const more = document.createElement('div');
|
|
282
|
+
more.className = 'search-result-message';
|
|
283
|
+
more.textContent = `... and ${pathResults.length - 10} more`;
|
|
284
|
+
section.appendChild(more);
|
|
285
|
+
}
|
|
286
|
+
container.appendChild(section);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Full-text results section
|
|
290
|
+
if (uniqueFullTextResults.length > 0) {
|
|
291
|
+
const section = document.createElement('div');
|
|
292
|
+
section.className = 'search-section';
|
|
293
|
+
const header = document.createElement('div');
|
|
294
|
+
header.className = 'search-section-header';
|
|
295
|
+
header.textContent = `Content Matches (${uniqueFullTextResults.length})`;
|
|
296
|
+
section.appendChild(header);
|
|
297
|
+
|
|
298
|
+
const limit = Math.min(uniqueFullTextResults.length, 10);
|
|
299
|
+
for (let i = 0; i < limit; i++) {
|
|
300
|
+
section.appendChild(this.createWidgetResultItem(uniqueFullTextResults[i]));
|
|
301
|
+
}
|
|
302
|
+
if (uniqueFullTextResults.length > 10) {
|
|
303
|
+
const more = document.createElement('div');
|
|
304
|
+
more.className = 'search-result-message';
|
|
305
|
+
more.textContent = `... and ${uniqueFullTextResults.length - 10} more`;
|
|
306
|
+
section.appendChild(more);
|
|
307
|
+
}
|
|
308
|
+
container.appendChild(section);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Create a search result item for the widget.
|
|
314
|
+
*/
|
|
315
|
+
createWidgetResultItem(result) {
|
|
316
|
+
const item = document.createElement('div');
|
|
317
|
+
item.className = 'search-result-item';
|
|
318
|
+
|
|
319
|
+
const title = document.createElement('div');
|
|
320
|
+
title.className = 'search-result-title';
|
|
321
|
+
title.textContent = result.title || 'Untitled';
|
|
322
|
+
|
|
323
|
+
const path = document.createElement('div');
|
|
324
|
+
path.className = 'search-result-path';
|
|
325
|
+
path.textContent = result.path || result.url || '';
|
|
326
|
+
|
|
327
|
+
item.appendChild(title);
|
|
328
|
+
item.appendChild(path);
|
|
329
|
+
|
|
330
|
+
item.addEventListener('click', () => {
|
|
331
|
+
window.location.href = result.url || result.path;
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
item.addEventListener('mouseenter', () => {
|
|
335
|
+
// Clear other selections
|
|
336
|
+
item.closest('.widget-search-results')?.querySelectorAll('.search-result-item').forEach(el => {
|
|
337
|
+
el.classList.remove('selected');
|
|
338
|
+
});
|
|
339
|
+
item.classList.add('selected');
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
return item;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
updateWidgetSearchSelection(items, index) {
|
|
346
|
+
items.forEach((item, i) => {
|
|
347
|
+
item.classList.toggle('selected', i === index);
|
|
348
|
+
});
|
|
349
|
+
if (index >= 0 && items[index]) {
|
|
350
|
+
items[index].scrollIntoView({ block: 'nearest' });
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Called when the search widget is activated.
|
|
356
|
+
*/
|
|
357
|
+
activateSearch() {
|
|
358
|
+
const input = this._widgetSearchInput;
|
|
359
|
+
if (input) {
|
|
360
|
+
// Focus with small delay to allow panel animation
|
|
361
|
+
setTimeout(() => input.focus(), 50);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Called when the search widget is deactivated.
|
|
367
|
+
*/
|
|
368
|
+
deactivateSearch() {
|
|
369
|
+
// Keep the search query so user can re-open and see results
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Initialize widgets when DOM is ready
|
|
374
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
375
|
+
window.widgetManager = new WidgetManager();
|
|
376
|
+
});
|
package/package.json
CHANGED
package/src/dev.js
CHANGED
|
@@ -28,6 +28,7 @@ import { buildFullTextIndex } from "./helper/fullTextIndex.js";
|
|
|
28
28
|
import { getAutomenu } from "./helper/automenu.js";
|
|
29
29
|
import { renderFile } from "./helper/fileRenderer.js";
|
|
30
30
|
import { processImage } from "./helper/imageProcessor.js";
|
|
31
|
+
import { generateBreadcrumbs } from "./helper/breadcrumbs.js";
|
|
31
32
|
import { extractImageReferences } from "./helper/imageExtractor.js";
|
|
32
33
|
import { recurse } from "./helper/recursive-readdir.js";
|
|
33
34
|
import { isFolderHidden, clearConfigCache } from "./helper/folderConfig.js";
|
|
@@ -358,8 +359,9 @@ async function renderDocument(urlPath) {
|
|
|
358
359
|
const sourceDir = dirname(sourcePath);
|
|
359
360
|
const autoIndexHtml = await generateAutoIndexHtmlFromSource(sourceDir, 2);
|
|
360
361
|
if (autoIndexHtml) {
|
|
361
|
-
// Wrap in template and return
|
|
362
|
-
|
|
362
|
+
// Wrap in template and return — use parent folder name for title
|
|
363
|
+
const indexTitle = toTitleCase(basename(dirname(sourcePath)) || 'Index');
|
|
364
|
+
return await wrapInTemplate(autoIndexHtml, indexTitle, null, urlPath, sourcePath);
|
|
363
365
|
}
|
|
364
366
|
}
|
|
365
367
|
|
|
@@ -375,6 +377,20 @@ async function renderDocument(urlPath) {
|
|
|
375
377
|
});
|
|
376
378
|
|
|
377
379
|
const fileMeta = extractMetadata(rawBody);
|
|
380
|
+
|
|
381
|
+
// Title from filename (for index/home, use parent folder name)
|
|
382
|
+
const titleBase = (base === 'index' || base === 'home') ? basename(dirname(sourcePath)) : base;
|
|
383
|
+
const title = toTitleCase(titleBase || base);
|
|
384
|
+
if (!body || !body.trimStart().startsWith('<h1')) {
|
|
385
|
+
const h1Title = fileMeta?.title || title;
|
|
386
|
+
body = `<h1>${h1Title}</h1>\n` + (body || '');
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Inject breadcrumbs before the H1
|
|
390
|
+
const breadcrumbs = generateBreadcrumbs(dir, base, fileMeta);
|
|
391
|
+
if (breadcrumbs) {
|
|
392
|
+
body = breadcrumbs + body;
|
|
393
|
+
}
|
|
378
394
|
|
|
379
395
|
// Inject frontmatter table for markdown/mdx files
|
|
380
396
|
if ((type === '.md' || type === '.mdx') && fileMeta) {
|
|
@@ -401,7 +417,6 @@ async function renderDocument(urlPath) {
|
|
|
401
417
|
// Process images in this document
|
|
402
418
|
body = await processDocumentImages(body, sourcePath);
|
|
403
419
|
|
|
404
|
-
const title = toTitleCase(base);
|
|
405
420
|
const html = await wrapInTemplate(body, title, fileMeta, urlPath, sourcePath);
|
|
406
421
|
|
|
407
422
|
// Cache rendered document
|
|
@@ -463,7 +478,7 @@ async function wrapInTemplate(body, title, fileMeta, urlPath, sourcePath) {
|
|
|
463
478
|
|
|
464
479
|
// Build replacements
|
|
465
480
|
const replacements = {
|
|
466
|
-
"${title}": title,
|
|
481
|
+
"${title}": fileMeta?.title || title,
|
|
467
482
|
"${menu}": menu,
|
|
468
483
|
"${meta}": JSON.stringify(fileMeta || {}),
|
|
469
484
|
"${transformedMetadata}": "",
|
|
@@ -478,16 +493,28 @@ async function wrapInTemplate(body, title, fileMeta, urlPath, sourcePath) {
|
|
|
478
493
|
const pattern = /\$\{(title|menu|meta|transformedMetadata|body|styleLink|customScript|searchIndex|footer)\}/g;
|
|
479
494
|
let finalHtml = template.replace(pattern, (match) => replacements[match] ?? match);
|
|
480
495
|
|
|
481
|
-
//
|
|
496
|
+
// Add menu data attributes to body
|
|
482
497
|
if (sourcePath && customMenus) {
|
|
483
498
|
const customMenuInfo = getCustomMenuForFile(sourcePath, source, customMenus);
|
|
484
499
|
if (customMenuInfo) {
|
|
485
|
-
const menuPosition = customMenuInfo.menuPosition || '
|
|
500
|
+
const menuPosition = customMenuInfo.menuPosition || 'top';
|
|
486
501
|
finalHtml = finalHtml.replace(
|
|
487
502
|
/<body([^>]*)>/,
|
|
488
503
|
`<body$1 data-custom-menu="${customMenuInfo.menuJsonPath}" data-menu-position="${menuPosition}">`
|
|
489
504
|
);
|
|
505
|
+
} else {
|
|
506
|
+
// No custom menu — default to top menu
|
|
507
|
+
finalHtml = finalHtml.replace(
|
|
508
|
+
/<body([^>]*)>/,
|
|
509
|
+
`<body$1 data-menu-position="top">`
|
|
510
|
+
);
|
|
490
511
|
}
|
|
512
|
+
} else {
|
|
513
|
+
// No custom menus at all — default to top menu
|
|
514
|
+
finalHtml = finalHtml.replace(
|
|
515
|
+
/<body([^>]*)>/,
|
|
516
|
+
`<body$1 data-menu-position="top">`
|
|
517
|
+
);
|
|
491
518
|
}
|
|
492
519
|
|
|
493
520
|
// Resolve relative URLs
|
|
@@ -568,7 +595,7 @@ async function buildBackgroundCaches() {
|
|
|
568
595
|
const customMenuPath = join(output, menuInfo.menuJsonPath);
|
|
569
596
|
const customMenuJson = JSON.stringify({
|
|
570
597
|
menuData: menuInfo.menuData,
|
|
571
|
-
menuPosition: menuInfo.menuPosition || '
|
|
598
|
+
menuPosition: menuInfo.menuPosition || 'top',
|
|
572
599
|
});
|
|
573
600
|
await outputFile(customMenuPath, customMenuJson);
|
|
574
601
|
}
|
|
@@ -615,7 +642,8 @@ async function buildBackgroundCaches() {
|
|
|
615
642
|
const ext = extname(article);
|
|
616
643
|
const base = basename(article, ext);
|
|
617
644
|
const relativePath = article.replace(source, '').replace(/\.(md|txt|yml)$/, '.html');
|
|
618
|
-
const
|
|
645
|
+
const titleBase = (base === 'index' || base === 'home') ? basename(dirname(article)) : base;
|
|
646
|
+
const title = toTitleCase(titleBase || base);
|
|
619
647
|
|
|
620
648
|
searchIndex.push({
|
|
621
649
|
title,
|
|
@@ -732,13 +760,13 @@ export async function dev({
|
|
|
732
760
|
if (url.startsWith('/public/custom-menu-') && url.endsWith('.json')) {
|
|
733
761
|
// Find the menuDir from the cached customMenus by matching the JSON path
|
|
734
762
|
let menuDir = null;
|
|
735
|
-
let menuPosition = '
|
|
763
|
+
let menuPosition = 'top';
|
|
736
764
|
|
|
737
765
|
if (devState.customMenus) {
|
|
738
766
|
for (const [dir, menuInfo] of devState.customMenus) {
|
|
739
767
|
if (menuInfo.menuJsonPath === url) {
|
|
740
768
|
menuDir = dir;
|
|
741
|
-
menuPosition = menuInfo.menuPosition || '
|
|
769
|
+
menuPosition = menuInfo.menuPosition || 'top';
|
|
742
770
|
break;
|
|
743
771
|
}
|
|
744
772
|
}
|
|
@@ -750,7 +778,7 @@ export async function dev({
|
|
|
750
778
|
if (menuInfo) {
|
|
751
779
|
const { frontmatter, body } = extractMenuFrontmatter(menuInfo.content);
|
|
752
780
|
const autoGenerate = frontmatter['auto-generate-menu'] === true || frontmatter['auto-generate-menu'] === 'true';
|
|
753
|
-
const depth = parseInt(frontmatter['menu-depth'], 10) ||
|
|
781
|
+
const depth = parseInt(frontmatter['menu-depth'], 10) || 10;
|
|
754
782
|
menuPosition = frontmatter['menu-position'] || menuPosition;
|
|
755
783
|
|
|
756
784
|
let menuData;
|
package/src/helper/automenu.js
CHANGED
|
@@ -109,6 +109,20 @@ function isIndexFile(baseName) {
|
|
|
109
109
|
return baseName.toLowerCase() === 'index';
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
+
/**
|
|
113
|
+
* Check if a file acts as the "index" for its parent folder.
|
|
114
|
+
* This is true for actual index files (index.md) and also for files
|
|
115
|
+
* whose name matches their parent folder (e.g. Arcanist/Arcanist.md).
|
|
116
|
+
* @param {string} filePath - Full path to the file
|
|
117
|
+
* @returns {boolean}
|
|
118
|
+
*/
|
|
119
|
+
function isFolderNamedFile(filePath) {
|
|
120
|
+
const ext = extname(filePath);
|
|
121
|
+
const fileBase = basename(filePath, ext).toLowerCase();
|
|
122
|
+
const parentBase = basename(dirname(filePath)).toLowerCase();
|
|
123
|
+
return fileBase === parentBase;
|
|
124
|
+
}
|
|
125
|
+
|
|
112
126
|
function hasIndexFile(dirPath) {
|
|
113
127
|
for (const ext of INDEX_EXTENSIONS) {
|
|
114
128
|
const indexPath = join(dirPath, `index${ext}`);
|
|
@@ -232,11 +246,29 @@ function resolveHref(rawHref, validPaths) {
|
|
|
232
246
|
return { href: rawHref, inactive: true, debug: debugTries.join(' | ') };
|
|
233
247
|
}
|
|
234
248
|
|
|
249
|
+
/**
|
|
250
|
+
* Recursively check if a directory-tree node contains any document files.
|
|
251
|
+
* @param {object} treeNode - A node from the directory-tree package
|
|
252
|
+
* @param {string[]} docExtensions - Extensions that count as documents
|
|
253
|
+
* @returns {boolean}
|
|
254
|
+
*/
|
|
255
|
+
function treeHasDocuments(treeNode, docExtensions) {
|
|
256
|
+
if (!treeNode.children) {
|
|
257
|
+
// Leaf node (file) — check its extension
|
|
258
|
+
return docExtensions.includes(extname(treeNode.path));
|
|
259
|
+
}
|
|
260
|
+
// Directory — recurse into children
|
|
261
|
+
return treeNode.children.some(child => treeHasDocuments(child, docExtensions));
|
|
262
|
+
}
|
|
263
|
+
|
|
235
264
|
// Build a flat tree structure with path info for JS navigation
|
|
236
265
|
// Set includeDebug=false to exclude debug fields and reduce JSON size
|
|
237
266
|
function buildMenuData(tree, source, validPaths, parentPath = '', includeDebug = true) {
|
|
238
267
|
const items = [];
|
|
239
268
|
|
|
269
|
+
// Document extensions that count as "real content"
|
|
270
|
+
const DOC_EXTENSIONS = ['.md', '.mdx', '.txt', '.html'];
|
|
271
|
+
|
|
240
272
|
// Files to hide from menu by default
|
|
241
273
|
const hiddenFiles = ['config.json', 'style.css', 'footer.md'];
|
|
242
274
|
|
|
@@ -270,13 +302,18 @@ function buildMenuData(tree, source, validPaths, parentPath = '', includeDebug =
|
|
|
270
302
|
continue; // Skip hidden folders
|
|
271
303
|
}
|
|
272
304
|
|
|
305
|
+
// Skip folders that contain no document files (recursively)
|
|
306
|
+
if (hasChildren && !treeHasDocuments(item, DOC_EXTENSIONS)) {
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
|
|
273
310
|
// Get folder config for custom label and icon (deprecated for labels, still used for icons/hidden)
|
|
274
311
|
const folderConfig = hasChildren ? getFolderConfig(item.path) : null;
|
|
275
312
|
|
|
276
313
|
// Determine the label - prefer menu-label from frontmatter
|
|
277
314
|
let label;
|
|
278
315
|
let sortKey;
|
|
279
|
-
const isIndex = !hasChildren && isIndexFile(baseName);
|
|
316
|
+
const isIndex = !hasChildren && (isIndexFile(baseName) || isFolderNamedFile(item.path));
|
|
280
317
|
|
|
281
318
|
if (hasChildren) {
|
|
282
319
|
// For folders, get label from index.md frontmatter, then config.json, then folder name
|
|
@@ -286,7 +323,12 @@ function buildMenuData(tree, source, validPaths, parentPath = '', includeDebug =
|
|
|
286
323
|
} else {
|
|
287
324
|
// For files, check frontmatter for menu-label
|
|
288
325
|
const fileLabel = getMenuLabelFromFile(item.path);
|
|
289
|
-
|
|
326
|
+
if (isIndex) {
|
|
327
|
+
// Index files (index.md or foldername.md) default to "Home" label
|
|
328
|
+
label = fileLabel || 'Home';
|
|
329
|
+
} else {
|
|
330
|
+
label = fileLabel || toDisplayName(baseName);
|
|
331
|
+
}
|
|
290
332
|
// Get sort key from file's frontmatter, fall back to baseName (not transformed label)
|
|
291
333
|
// This ensures menu-sort-as values can match original filenames consistently
|
|
292
334
|
const fileSortKey = getMenuSortAsFromFile(item.path);
|
|
@@ -347,7 +389,7 @@ function buildMenuData(tree, source, validPaths, parentPath = '', includeDebug =
|
|
|
347
389
|
items.push(menuItem);
|
|
348
390
|
}
|
|
349
391
|
|
|
350
|
-
// Sort: folders first, then index files, then
|
|
392
|
+
// Sort: folders first (a-z), then index files, then other files (a-z)
|
|
351
393
|
return items.sort((a, b) => {
|
|
352
394
|
// Folders always come first
|
|
353
395
|
if (a.hasChildren && !b.hasChildren) return -1;
|
|
@@ -355,31 +397,79 @@ function buildMenuData(tree, source, validPaths, parentPath = '', includeDebug =
|
|
|
355
397
|
// Index files come before other files (after folders)
|
|
356
398
|
if (a.isIndex && !b.isIndex) return -1;
|
|
357
399
|
if (b.isIndex && !a.isIndex) return 1;
|
|
358
|
-
// Alphabetical sort by sortKey (
|
|
359
|
-
|
|
360
|
-
|
|
400
|
+
// Alphabetical sort by sortKey (case-insensitive)
|
|
401
|
+
const aKey = (a.sortKey || '').toLowerCase();
|
|
402
|
+
const bKey = (b.sortKey || '').toLowerCase();
|
|
403
|
+
if (aKey > bKey) return 1;
|
|
404
|
+
if (aKey < bKey) return -1;
|
|
361
405
|
return 0;
|
|
362
406
|
});
|
|
363
407
|
}
|
|
364
408
|
|
|
409
|
+
/**
|
|
410
|
+
* Post-process menu data to collapse single-document folders.
|
|
411
|
+
* When a folder contains only an index file (index.md) or a foldername-matching
|
|
412
|
+
* file (e.g. Arcanist/Arcanist.md) and no other children, the folder is replaced
|
|
413
|
+
* with a direct link to that document using the folder's label.
|
|
414
|
+
*/
|
|
415
|
+
function collapseSingleDocFolders(items) {
|
|
416
|
+
return items.map(item => {
|
|
417
|
+
if (!item.hasChildren || !item.children) return item;
|
|
418
|
+
|
|
419
|
+
// Recurse first so nested single-doc folders are collapsed bottom-up
|
|
420
|
+
item.children = collapseSingleDocFolders(item.children);
|
|
421
|
+
|
|
422
|
+
// Check if the only child(ren) are index-like files (no sub-folders)
|
|
423
|
+
const nonIndexChildren = item.children.filter(c => !c.isIndex);
|
|
424
|
+
if (nonIndexChildren.length === 0 && item.children.length > 0) {
|
|
425
|
+
// All children are index files — collapse to a single link
|
|
426
|
+
const indexChild = item.children[0];
|
|
427
|
+
return {
|
|
428
|
+
...item,
|
|
429
|
+
hasChildren: false,
|
|
430
|
+
children: undefined,
|
|
431
|
+
href: indexChild.href || item.href,
|
|
432
|
+
isIndex: false,
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
return item;
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
|
|
365
440
|
export async function getAutomenu(source, validPaths) {
|
|
366
441
|
const tree = dirTree(source, {
|
|
367
442
|
exclude: /[\/\\]\.|node_modules/, // Exclude hidden folders (starting with .) and node_modules
|
|
368
443
|
});
|
|
369
444
|
|
|
370
445
|
// Build menu data WITHOUT debug fields for smaller JSON
|
|
371
|
-
|
|
446
|
+
let menuData = buildMenuData(tree, source, validPaths, '', false);
|
|
447
|
+
|
|
448
|
+
// Post-process: collapse single-document folders into direct links
|
|
449
|
+
menuData = collapseSingleDocFolders(menuData);
|
|
372
450
|
|
|
373
451
|
// Get root config for openMenuItems setting
|
|
374
452
|
const rootConfig = getRootConfig(source);
|
|
375
453
|
const openMenuItems = rootConfig?.openMenuItems || [];
|
|
376
454
|
|
|
377
|
-
//
|
|
455
|
+
// Partition top-level items: files go under Home, folders stay at top level
|
|
456
|
+
const topLevelFolders = menuData.filter(item => item.hasChildren);
|
|
457
|
+
const topLevelFiles = menuData.filter(item => !item.hasChildren);
|
|
458
|
+
|
|
459
|
+
// Build Home item with top-level files as children
|
|
378
460
|
const homeResolved = resolveHref('/', validPaths);
|
|
379
|
-
const
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
461
|
+
const homeItem = {
|
|
462
|
+
label: 'Home',
|
|
463
|
+
path: '',
|
|
464
|
+
href: homeResolved.href,
|
|
465
|
+
hasChildren: topLevelFiles.length > 0,
|
|
466
|
+
icon: `<span class="menu-icon">${HOME_ICON}</span>`,
|
|
467
|
+
};
|
|
468
|
+
if (topLevelFiles.length > 0) {
|
|
469
|
+
homeItem.children = topLevelFiles;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const fullMenuData = [homeItem, ...topLevelFolders];
|
|
383
473
|
|
|
384
474
|
// Embed the openMenuItems config as JSON (small, safe to embed)
|
|
385
475
|
const menuConfigScript = `<script type="application/json" id="menu-config">${JSON.stringify({ openMenuItems })}</script>`;
|