@kenjura/ursa 0.51.0 → 0.53.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 +14 -0
- package/meta/default-template.html +1 -13
- package/meta/default.css +8 -0
- package/meta/menu.js +44 -13
- package/meta/search.js +147 -47
- package/package.json +2 -1
- package/src/helper/automenu.js +23 -12
- package/src/jobs/generate.js +16 -18
- package/src/serve.js +10 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
# 0.53.0
|
|
2
|
+
2025-01-01
|
|
3
|
+
|
|
4
|
+
### Menu Size Optimization
|
|
5
|
+
- **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).
|
|
6
|
+
- **Async Menu Loading**: Menu data is fetched asynchronously after page render, showing a "Loading menu..." indicator until ready.
|
|
7
|
+
- **Debug Fields Removed**: Menu JSON no longer includes debug/inactive fields, reducing JSON size further.
|
|
8
|
+
- **Gzip Compression**: Development server now uses gzip compression for all responses, significantly reducing transfer size for JSON and HTML files.
|
|
9
|
+
|
|
10
|
+
# 0.52.0
|
|
11
|
+
2025-12-21
|
|
12
|
+
|
|
13
|
+
- Fixed search results not displaying correctly when no matches are found
|
|
14
|
+
|
|
1
15
|
# 0.51.0
|
|
2
16
|
2025-12-21
|
|
3
17
|
|
|
@@ -6,19 +6,6 @@
|
|
|
6
6
|
<title>${title}</title>
|
|
7
7
|
<link rel="stylesheet" href="/public/default.css" />
|
|
8
8
|
${styleLink}
|
|
9
|
-
<script>
|
|
10
|
-
// Search index loaded asynchronously from separate file to reduce page size
|
|
11
|
-
window.SEARCH_INDEX = ${searchIndex};
|
|
12
|
-
// Lazy load full search index if placeholder is empty
|
|
13
|
-
if (!window.SEARCH_INDEX || window.SEARCH_INDEX.length === 0) {
|
|
14
|
-
fetch('/public/search-index.json')
|
|
15
|
-
.then(r => r.json())
|
|
16
|
-
.then(data => { window.SEARCH_INDEX = data; })
|
|
17
|
-
.catch(() => { window.SEARCH_INDEX = []; });
|
|
18
|
-
}
|
|
19
|
-
</script>
|
|
20
|
-
<script src="/public/search.js"></script>
|
|
21
|
-
|
|
22
9
|
</head>
|
|
23
10
|
|
|
24
11
|
<body data-template-id="default">
|
|
@@ -47,6 +34,7 @@
|
|
|
47
34
|
<script src="/public/toc.js"></script>
|
|
48
35
|
<script src="/public/toc-generator.js"></script>
|
|
49
36
|
<script src="/public/menu.js"></script>
|
|
37
|
+
<script src="/public/search.js"></script>
|
|
50
38
|
<script src="/public/sectionify.js"></script>
|
|
51
39
|
<script src="/public/sticky.js"></script>
|
|
52
40
|
</body>
|
package/meta/default.css
CHANGED
|
@@ -142,6 +142,14 @@ nav#nav-global {
|
|
|
142
142
|
opacity: 0.7;
|
|
143
143
|
}
|
|
144
144
|
|
|
145
|
+
.search-result-message {
|
|
146
|
+
padding: 16px;
|
|
147
|
+
text-align: center;
|
|
148
|
+
color: var(--text-color);
|
|
149
|
+
opacity: 0.7;
|
|
150
|
+
font-style: italic;
|
|
151
|
+
}
|
|
152
|
+
|
|
145
153
|
nav#nav-main {
|
|
146
154
|
position: fixed;
|
|
147
155
|
top: calc(var(--global-nav-height) + 0.5rem);
|
package/meta/menu.js
CHANGED
|
@@ -2,19 +2,12 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
|
2
2
|
const navMain = document.querySelector('nav#nav-main');
|
|
3
3
|
if (!navMain) return;
|
|
4
4
|
|
|
5
|
-
//
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
// State - menu data will be loaded asynchronously
|
|
6
|
+
let menuData = null;
|
|
7
|
+
let menuDataLoaded = false;
|
|
8
|
+
let menuDataLoading = false;
|
|
8
9
|
|
|
9
|
-
|
|
10
|
-
try {
|
|
11
|
-
menuData = JSON.parse(menuDataScript.textContent);
|
|
12
|
-
} catch (e) {
|
|
13
|
-
console.error('Failed to parse menu data:', e);
|
|
14
|
-
return;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
// Load menu config from embedded JSON (contains openMenuItems)
|
|
10
|
+
// Load menu config from embedded JSON (contains openMenuItems) - this is small, so it's embedded
|
|
18
11
|
const menuConfigScript = document.getElementById('menu-config');
|
|
19
12
|
let menuConfig = { openMenuItems: [] };
|
|
20
13
|
if (menuConfigScript) {
|
|
@@ -39,9 +32,39 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
|
39
32
|
|
|
40
33
|
// Helper to check if we're on mobile
|
|
41
34
|
const isMobile = () => window.matchMedia('(max-width: 800px)').matches;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Load menu data from external JSON file
|
|
38
|
+
* This is done asynchronously to avoid blocking page render
|
|
39
|
+
*/
|
|
40
|
+
async function loadMenuData() {
|
|
41
|
+
if (menuDataLoaded || menuDataLoading) return;
|
|
42
|
+
menuDataLoading = true;
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
const response = await fetch('/public/menu-data.json');
|
|
46
|
+
if (!response.ok) {
|
|
47
|
+
throw new Error(`HTTP ${response.status}`);
|
|
48
|
+
}
|
|
49
|
+
menuData = await response.json();
|
|
50
|
+
menuDataLoaded = true;
|
|
51
|
+
|
|
52
|
+
// Re-render menu now that we have full data
|
|
53
|
+
initializeFromCurrentPage();
|
|
54
|
+
} catch (error) {
|
|
55
|
+
console.error('Failed to load menu data:', error);
|
|
56
|
+
menuDataLoaded = true; // Mark as loaded to prevent retries
|
|
57
|
+
} finally {
|
|
58
|
+
menuDataLoading = false;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Start loading menu data immediately
|
|
63
|
+
loadMenuData();
|
|
42
64
|
|
|
43
65
|
// Get items at a specific path
|
|
44
66
|
function getItemsAtPath(path) {
|
|
67
|
+
if (!menuData) return [];
|
|
45
68
|
let items = menuData;
|
|
46
69
|
for (const segment of path) {
|
|
47
70
|
const folder = items.find(item => item.path === (path.slice(0, path.indexOf(segment) + 1).join('/') || segment));
|
|
@@ -56,6 +79,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
|
56
79
|
|
|
57
80
|
// Find item by path
|
|
58
81
|
function findItemByPath(pathString) {
|
|
82
|
+
if (!menuData) return null;
|
|
59
83
|
const segments = pathString.split('/').filter(Boolean);
|
|
60
84
|
let items = menuData;
|
|
61
85
|
let item = null;
|
|
@@ -98,6 +122,12 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
|
98
122
|
|
|
99
123
|
// Render menu at current path
|
|
100
124
|
function renderMenu() {
|
|
125
|
+
// Wait for menu data to load
|
|
126
|
+
if (!menuData) {
|
|
127
|
+
menuContainer.innerHTML = '<li class="menu-loading">Loading menu...</li>';
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
101
131
|
// Get items for current level (level 1)
|
|
102
132
|
let level1Items;
|
|
103
133
|
if (currentPath.length === 0) {
|
|
@@ -421,5 +451,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
|
421
451
|
renderMenu();
|
|
422
452
|
}
|
|
423
453
|
|
|
424
|
-
initializeFromCurrentPage()
|
|
454
|
+
// Initial render shows loading state, then loadMenuData() will call initializeFromCurrentPage() when ready
|
|
455
|
+
renderMenu();
|
|
425
456
|
});
|
package/meta/search.js
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
// Global search functionality with typeahead
|
|
2
|
+
// Supports lazy-loading of search index for large datasets
|
|
3
|
+
|
|
2
4
|
class GlobalSearch {
|
|
3
5
|
constructor() {
|
|
4
6
|
this.searchInput = document.getElementById('global-search');
|
|
5
7
|
this.searchResults = null;
|
|
6
|
-
this.searchIndex =
|
|
8
|
+
this.searchIndex = null; // Will be loaded asynchronously
|
|
9
|
+
this.indexLoading = false;
|
|
10
|
+
this.indexLoaded = false;
|
|
7
11
|
this.currentSelection = -1;
|
|
12
|
+
this._lastResults = null;
|
|
13
|
+
this.MIN_QUERY_LENGTH = 3;
|
|
8
14
|
|
|
9
15
|
if (!this.searchInput) return;
|
|
10
16
|
|
|
@@ -14,6 +20,8 @@ class GlobalSearch {
|
|
|
14
20
|
init() {
|
|
15
21
|
this.createResultsContainer();
|
|
16
22
|
this.bindEvents();
|
|
23
|
+
// Start loading the index immediately (but don't block)
|
|
24
|
+
this.loadSearchIndex();
|
|
17
25
|
}
|
|
18
26
|
|
|
19
27
|
createResultsContainer() {
|
|
@@ -23,6 +31,43 @@ class GlobalSearch {
|
|
|
23
31
|
this.searchInput.parentNode.appendChild(this.searchResults);
|
|
24
32
|
}
|
|
25
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Load search index from external JSON file
|
|
36
|
+
* This is done asynchronously to avoid blocking page render
|
|
37
|
+
*/
|
|
38
|
+
async loadSearchIndex() {
|
|
39
|
+
// Check if index was already embedded in page (legacy support)
|
|
40
|
+
if (window.SEARCH_INDEX && Array.isArray(window.SEARCH_INDEX) && window.SEARCH_INDEX.length > 0) {
|
|
41
|
+
this.searchIndex = window.SEARCH_INDEX;
|
|
42
|
+
this.indexLoaded = true;
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
this.indexLoading = true;
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
const response = await fetch('/public/search-index.json');
|
|
50
|
+
if (!response.ok) {
|
|
51
|
+
throw new Error(`HTTP ${response.status}`);
|
|
52
|
+
}
|
|
53
|
+
const data = await response.json();
|
|
54
|
+
this.searchIndex = data;
|
|
55
|
+
this.indexLoaded = true;
|
|
56
|
+
window.SEARCH_INDEX = data; // Cache globally for potential reuse
|
|
57
|
+
|
|
58
|
+
// If user was waiting, trigger search now
|
|
59
|
+
if (this.searchInput.value.length >= this.MIN_QUERY_LENGTH) {
|
|
60
|
+
this.handleSearch(this.searchInput.value);
|
|
61
|
+
}
|
|
62
|
+
} catch (error) {
|
|
63
|
+
console.error('Failed to load search index:', error);
|
|
64
|
+
this.searchIndex = [];
|
|
65
|
+
this.indexLoaded = true; // Mark as loaded (with empty) to stop loading indicator
|
|
66
|
+
} finally {
|
|
67
|
+
this.indexLoading = false;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
26
71
|
bindEvents() {
|
|
27
72
|
// Input events
|
|
28
73
|
this.searchInput.addEventListener('input', (e) => {
|
|
@@ -57,55 +102,103 @@ class GlobalSearch {
|
|
|
57
102
|
}
|
|
58
103
|
|
|
59
104
|
handleSearch(query) {
|
|
60
|
-
|
|
105
|
+
const trimmedQuery = (query || '').trim();
|
|
106
|
+
|
|
107
|
+
// Clear results for empty query
|
|
108
|
+
if (!trimmedQuery) {
|
|
61
109
|
this.hideResults();
|
|
62
110
|
return;
|
|
63
111
|
}
|
|
64
112
|
|
|
65
|
-
|
|
66
|
-
this.
|
|
113
|
+
// Show minimum character message
|
|
114
|
+
if (trimmedQuery.length < this.MIN_QUERY_LENGTH) {
|
|
115
|
+
this.showMessage(`Type at least ${this.MIN_QUERY_LENGTH} characters to search`);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Show loading indicator if index isn't ready
|
|
120
|
+
if (this.indexLoading || !this.indexLoaded) {
|
|
121
|
+
this.showMessage('Loading search index...');
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const results = this.search(trimmedQuery);
|
|
126
|
+
this._lastResults = results;
|
|
127
|
+
this.displayResults(results, trimmedQuery);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Show a message in the results dropdown (for loading, errors, etc.)
|
|
132
|
+
*/
|
|
133
|
+
showMessage(message) {
|
|
134
|
+
this.searchResults.innerHTML = '';
|
|
135
|
+
this.currentSelection = -1;
|
|
136
|
+
|
|
137
|
+
const item = document.createElement('div');
|
|
138
|
+
item.className = 'search-result-message';
|
|
139
|
+
item.textContent = message;
|
|
140
|
+
this.searchResults.appendChild(item);
|
|
141
|
+
|
|
142
|
+
this.showResults();
|
|
67
143
|
}
|
|
68
144
|
|
|
69
145
|
search(query) {
|
|
146
|
+
if (!this.searchIndex || !Array.isArray(this.searchIndex)) {
|
|
147
|
+
return [];
|
|
148
|
+
}
|
|
149
|
+
|
|
70
150
|
const normalizedQuery = query.toLowerCase().trim();
|
|
151
|
+
const queryWords = normalizedQuery.split(/\s+/);
|
|
71
152
|
const results = [];
|
|
72
153
|
|
|
73
154
|
// Search through the index
|
|
74
155
|
this.searchIndex.forEach(item => {
|
|
75
|
-
const
|
|
76
|
-
const
|
|
77
|
-
const
|
|
156
|
+
const titleLower = (item.title || '').toLowerCase();
|
|
157
|
+
const pathLower = (item.path || '').toLowerCase();
|
|
158
|
+
const contentLower = (item.content || '').toLowerCase();
|
|
78
159
|
|
|
79
|
-
if
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
160
|
+
// Check if all query words match somewhere
|
|
161
|
+
const allWordsMatch = queryWords.every(word =>
|
|
162
|
+
titleLower.includes(word) ||
|
|
163
|
+
pathLower.includes(word) ||
|
|
164
|
+
contentLower.includes(word)
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
if (!allWordsMatch) return;
|
|
168
|
+
|
|
169
|
+
let score = 0;
|
|
170
|
+
|
|
171
|
+
// Boost exact title matches
|
|
172
|
+
if (titleLower === normalizedQuery) score += 100;
|
|
173
|
+
else if (titleLower.startsWith(normalizedQuery)) score += 50;
|
|
174
|
+
else if (titleLower.includes(normalizedQuery)) score += 25;
|
|
175
|
+
|
|
176
|
+
// Boost path matches
|
|
177
|
+
if (pathLower.includes(normalizedQuery)) score += 10;
|
|
178
|
+
|
|
179
|
+
// Content matches get lower score
|
|
180
|
+
if (contentLower.includes(normalizedQuery)) score += 5;
|
|
181
|
+
|
|
182
|
+
// Bonus for each word match in title
|
|
183
|
+
queryWords.forEach(word => {
|
|
184
|
+
if (titleLower.includes(word)) score += 3;
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
results.push({ ...item, score });
|
|
95
188
|
});
|
|
96
189
|
|
|
97
190
|
// Sort by score, then by title
|
|
98
191
|
return results
|
|
99
192
|
.sort((a, b) => {
|
|
100
193
|
if (b.score !== a.score) return b.score - a.score;
|
|
101
|
-
return a.title.localeCompare(b.title);
|
|
194
|
+
return (a.title || '').localeCompare(b.title || '');
|
|
102
195
|
})
|
|
103
196
|
.slice(0, 10); // Limit to 10 results
|
|
104
197
|
}
|
|
105
198
|
|
|
106
|
-
displayResults(results) {
|
|
199
|
+
displayResults(results, query) {
|
|
107
200
|
if (results.length === 0) {
|
|
108
|
-
this.
|
|
201
|
+
this.showMessage(`No results for "${query}"`);
|
|
109
202
|
return;
|
|
110
203
|
}
|
|
111
204
|
|
|
@@ -119,11 +212,11 @@ class GlobalSearch {
|
|
|
119
212
|
|
|
120
213
|
const title = document.createElement('div');
|
|
121
214
|
title.className = 'search-result-title';
|
|
122
|
-
title.textContent = result.title;
|
|
215
|
+
title.textContent = result.title || 'Untitled';
|
|
123
216
|
|
|
124
217
|
const path = document.createElement('div');
|
|
125
218
|
path.className = 'search-result-path';
|
|
126
|
-
path.textContent = result.path;
|
|
219
|
+
path.textContent = result.path || result.url || '';
|
|
127
220
|
|
|
128
221
|
item.appendChild(title);
|
|
129
222
|
item.appendChild(path);
|
|
@@ -133,6 +226,12 @@ class GlobalSearch {
|
|
|
133
226
|
this.navigateToResult(result);
|
|
134
227
|
});
|
|
135
228
|
|
|
229
|
+
// Mouse hover selection
|
|
230
|
+
item.addEventListener('mouseenter', () => {
|
|
231
|
+
this.currentSelection = index;
|
|
232
|
+
this.updateSelection();
|
|
233
|
+
});
|
|
234
|
+
|
|
136
235
|
this.searchResults.appendChild(item);
|
|
137
236
|
});
|
|
138
237
|
|
|
@@ -154,24 +253,24 @@ class GlobalSearch {
|
|
|
154
253
|
switch (e.key) {
|
|
155
254
|
case 'ArrowDown':
|
|
156
255
|
e.preventDefault();
|
|
157
|
-
|
|
158
|
-
|
|
256
|
+
if (items.length > 0) {
|
|
257
|
+
this.currentSelection = Math.min(this.currentSelection + 1, items.length - 1);
|
|
258
|
+
this.updateSelection();
|
|
259
|
+
}
|
|
159
260
|
break;
|
|
160
261
|
|
|
161
262
|
case 'ArrowUp':
|
|
162
263
|
e.preventDefault();
|
|
163
|
-
|
|
164
|
-
|
|
264
|
+
if (items.length > 0) {
|
|
265
|
+
this.currentSelection = Math.max(this.currentSelection - 1, 0);
|
|
266
|
+
this.updateSelection();
|
|
267
|
+
}
|
|
165
268
|
break;
|
|
166
269
|
|
|
167
270
|
case 'Enter':
|
|
168
271
|
e.preventDefault();
|
|
169
|
-
if (this.currentSelection >= 0 &&
|
|
170
|
-
|
|
171
|
-
const results = this.getLastSearchResults();
|
|
172
|
-
if (results && results[index]) {
|
|
173
|
-
this.navigateToResult(results[index]);
|
|
174
|
-
}
|
|
272
|
+
if (this.currentSelection >= 0 && this._lastResults && this._lastResults[this.currentSelection]) {
|
|
273
|
+
this.navigateToResult(this._lastResults[this.currentSelection]);
|
|
175
274
|
}
|
|
176
275
|
break;
|
|
177
276
|
|
|
@@ -187,22 +286,23 @@ class GlobalSearch {
|
|
|
187
286
|
items.forEach((item, index) => {
|
|
188
287
|
item.classList.toggle('selected', index === this.currentSelection);
|
|
189
288
|
});
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
if (!this._lastResults) {
|
|
195
|
-
this._lastResults = this.search(this.searchInput.value);
|
|
289
|
+
|
|
290
|
+
// Scroll selected item into view
|
|
291
|
+
if (this.currentSelection >= 0 && items[this.currentSelection]) {
|
|
292
|
+
items[this.currentSelection].scrollIntoView({ block: 'nearest' });
|
|
196
293
|
}
|
|
197
|
-
return this._lastResults;
|
|
198
294
|
}
|
|
199
295
|
|
|
200
296
|
navigateToResult(result) {
|
|
201
|
-
|
|
297
|
+
if (result.url) {
|
|
298
|
+
window.location.href = result.url;
|
|
299
|
+
} else if (result.path) {
|
|
300
|
+
window.location.href = result.path;
|
|
301
|
+
}
|
|
202
302
|
}
|
|
203
303
|
}
|
|
204
304
|
|
|
205
305
|
// Initialize when DOM is loaded
|
|
206
306
|
document.addEventListener('DOMContentLoaded', () => {
|
|
207
|
-
new GlobalSearch();
|
|
307
|
+
window.globalSearch = new GlobalSearch();
|
|
208
308
|
});
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@kenjura/ursa",
|
|
3
3
|
"author": "Andrew London <andrew@kenjura.com>",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"version": "0.
|
|
5
|
+
"version": "0.53.0",
|
|
6
6
|
"description": "static site generator from MD/wikitext/YML",
|
|
7
7
|
"main": "lib/index.js",
|
|
8
8
|
"bin": {
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
"url": "git+https://github.com/kenjura/ursa.git"
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
|
+
"compression": "^1.7.4",
|
|
27
28
|
"directory-tree": "^3.3.2",
|
|
28
29
|
"express": "^4.18.2",
|
|
29
30
|
"fs-extra": "^10.1.0",
|
package/src/helper/automenu.js
CHANGED
|
@@ -132,7 +132,8 @@ function resolveHref(rawHref, validPaths) {
|
|
|
132
132
|
}
|
|
133
133
|
|
|
134
134
|
// Build a flat tree structure with path info for JS navigation
|
|
135
|
-
|
|
135
|
+
// Set includeDebug=false to exclude debug fields and reduce JSON size
|
|
136
|
+
function buildMenuData(tree, source, validPaths, parentPath = '', includeDebug = true) {
|
|
136
137
|
const items = [];
|
|
137
138
|
|
|
138
139
|
// Files to hide from menu by default
|
|
@@ -192,14 +193,21 @@ function buildMenuData(tree, source, validPaths, parentPath = '') {
|
|
|
192
193
|
label,
|
|
193
194
|
path: folderPath,
|
|
194
195
|
href,
|
|
195
|
-
inactive,
|
|
196
|
-
debug,
|
|
197
196
|
hasChildren,
|
|
198
197
|
icon,
|
|
199
198
|
};
|
|
200
199
|
|
|
200
|
+
// Only include debug and inactive fields if requested (for smaller JSON)
|
|
201
|
+
if (includeDebug) {
|
|
202
|
+
menuItem.inactive = inactive;
|
|
203
|
+
menuItem.debug = debug;
|
|
204
|
+
} else if (inactive) {
|
|
205
|
+
// Only include inactive if true (to save space)
|
|
206
|
+
menuItem.inactive = true;
|
|
207
|
+
}
|
|
208
|
+
|
|
201
209
|
if (hasChildren) {
|
|
202
|
-
menuItem.children = buildMenuData(item, source, validPaths, folderPath);
|
|
210
|
+
menuItem.children = buildMenuData(item, source, validPaths, folderPath, includeDebug);
|
|
203
211
|
}
|
|
204
212
|
|
|
205
213
|
items.push(menuItem);
|
|
@@ -218,7 +226,9 @@ export async function getAutomenu(source, validPaths) {
|
|
|
218
226
|
const tree = dirTree(source, {
|
|
219
227
|
exclude: /[\/\\]\.|node_modules/, // Exclude hidden folders (starting with .) and node_modules
|
|
220
228
|
});
|
|
221
|
-
|
|
229
|
+
|
|
230
|
+
// Build menu data WITHOUT debug fields for smaller JSON
|
|
231
|
+
const menuData = buildMenuData(tree, source, validPaths, '', false);
|
|
222
232
|
|
|
223
233
|
// Get root config for openMenuItems setting
|
|
224
234
|
const rootConfig = getRootConfig(source);
|
|
@@ -227,14 +237,11 @@ export async function getAutomenu(source, validPaths) {
|
|
|
227
237
|
// Add home item with resolved href
|
|
228
238
|
const homeResolved = resolveHref('/', validPaths);
|
|
229
239
|
const fullMenuData = [
|
|
230
|
-
{ label: 'Home', path: '', href: homeResolved.href,
|
|
240
|
+
{ label: 'Home', path: '', href: homeResolved.href, hasChildren: false, icon: `<span class="menu-icon">${HOME_ICON}</span>` },
|
|
231
241
|
...menuData
|
|
232
242
|
];
|
|
233
243
|
|
|
234
|
-
// Embed the
|
|
235
|
-
const menuDataScript = `<script type="application/json" id="menu-data">${JSON.stringify(fullMenuData)}</script>`;
|
|
236
|
-
|
|
237
|
-
// Embed the openMenuItems config as separate JSON
|
|
244
|
+
// Embed the openMenuItems config as JSON (small, safe to embed)
|
|
238
245
|
const menuConfigScript = `<script type="application/json" id="menu-config">${JSON.stringify({ openMenuItems })}</script>`;
|
|
239
246
|
|
|
240
247
|
// Render the breadcrumb header (hidden by default, shown when navigating)
|
|
@@ -245,10 +252,14 @@ export async function getAutomenu(source, validPaths) {
|
|
|
245
252
|
<span class="menu-current-path"></span>
|
|
246
253
|
</div>`;
|
|
247
254
|
|
|
248
|
-
// Render the initial menu (root level)
|
|
255
|
+
// Render the initial menu (root level only - children loaded from external JSON)
|
|
249
256
|
const menuHtml = renderMenuLevel(fullMenuData, 0);
|
|
250
257
|
|
|
251
|
-
|
|
258
|
+
// Return both the HTML for embedding and the full menu data for the static JSON file
|
|
259
|
+
return {
|
|
260
|
+
html: `${menuConfigScript}${breadcrumbHtml}<ul class="menu-level" data-level="0">${menuHtml}</ul>`,
|
|
261
|
+
menuData: fullMenuData
|
|
262
|
+
};
|
|
252
263
|
}
|
|
253
264
|
|
|
254
265
|
function renderMenuLevel(items, level) {
|
package/src/jobs/generate.js
CHANGED
|
@@ -323,7 +323,9 @@ export async function generate({
|
|
|
323
323
|
const validPaths = buildValidPaths(allSourceFilenamesThatAreArticles, source, allSourceFilenamesThatAreDirectories);
|
|
324
324
|
progress.log(`Built ${validPaths.size} valid paths for link validation`);
|
|
325
325
|
|
|
326
|
-
const
|
|
326
|
+
const menuResult = await getMenu(allSourceFilenames, source, validPaths);
|
|
327
|
+
const menu = menuResult.html;
|
|
328
|
+
const menuData = menuResult.menuData;
|
|
327
329
|
|
|
328
330
|
// Get and increment build ID from .ursa.json
|
|
329
331
|
const buildId = getAndIncrementBuildId(resolve(_source));
|
|
@@ -549,6 +551,13 @@ export async function generate({
|
|
|
549
551
|
progress.log(`Writing search index with ${searchIndex.length} entries`);
|
|
550
552
|
await outputFile(searchIndexPath, JSON.stringify(searchIndex));
|
|
551
553
|
|
|
554
|
+
// Write menu data as a separate JSON file (not embedded in each page)
|
|
555
|
+
// This dramatically reduces HTML file sizes for large sites
|
|
556
|
+
const menuDataPath = join(output, 'public', 'menu-data.json');
|
|
557
|
+
const menuDataJson = JSON.stringify(menuData);
|
|
558
|
+
progress.log(`Writing menu data (${(menuDataJson.length / 1024).toFixed(1)} KB)`);
|
|
559
|
+
await outputFile(menuDataPath, menuDataJson);
|
|
560
|
+
|
|
552
561
|
// Process directory indices with batched concurrency
|
|
553
562
|
const totalDirs = allSourceFilenamesThatAreDirectories.length;
|
|
554
563
|
let processedDirs = 0;
|
|
@@ -1084,23 +1093,12 @@ async function getTemplates(meta) {
|
|
|
1084
1093
|
async function getMenu(allSourceFilenames, source, validPaths) {
|
|
1085
1094
|
// todo: handle various incarnations of menu filename
|
|
1086
1095
|
|
|
1087
|
-
const
|
|
1088
|
-
const menuBody = renderFile({ fileContents:
|
|
1089
|
-
return
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
// );
|
|
1094
|
-
// console.log({ allMenus });
|
|
1095
|
-
// if (allMenus.length === 0) return "";
|
|
1096
|
-
|
|
1097
|
-
// // pick best menu...TODO: actually apply logic here
|
|
1098
|
-
// const bestMenu = allMenus[0];
|
|
1099
|
-
// const rawBody = await readFile(bestMenu, "utf8");
|
|
1100
|
-
// const type = parse(bestMenu).ext;
|
|
1101
|
-
// const menuBody = renderFile({ fileContents: rawBody, type });
|
|
1102
|
-
|
|
1103
|
-
// return menuBody;
|
|
1096
|
+
const menuResult = await getAutomenu(source, validPaths);
|
|
1097
|
+
const menuBody = renderFile({ fileContents: menuResult.html, type: ".md" });
|
|
1098
|
+
return {
|
|
1099
|
+
html: menuBody,
|
|
1100
|
+
menuData: menuResult.menuData
|
|
1101
|
+
};
|
|
1104
1102
|
}
|
|
1105
1103
|
|
|
1106
1104
|
async function getTransformedMetadata(dirname, metadata) {
|
package/src/serve.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import express from "express";
|
|
2
|
+
import compression from "compression";
|
|
2
3
|
import watch from "node-watch";
|
|
3
4
|
import { generate, regenerateSingleFile, clearWatchCache } from "./jobs/generate.js";
|
|
4
5
|
import { join, resolve, dirname } from "path";
|
|
@@ -252,6 +253,15 @@ export async function serve({
|
|
|
252
253
|
function serveFiles(outputDir, port = 8080) {
|
|
253
254
|
const app = express();
|
|
254
255
|
|
|
256
|
+
// Enable gzip compression for all responses
|
|
257
|
+
// This significantly reduces transfer size for JSON and HTML files
|
|
258
|
+
app.use(compression({
|
|
259
|
+
// Compress everything over 1KB
|
|
260
|
+
threshold: 1024,
|
|
261
|
+
// Use default compression level (good balance of speed vs size)
|
|
262
|
+
level: 6
|
|
263
|
+
}));
|
|
264
|
+
|
|
255
265
|
app.use(
|
|
256
266
|
express.static(outputDir, { extensions: ["html"], index: "index.html" })
|
|
257
267
|
);
|