@kenjura/ursa 0.10.0 → 0.33.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 +162 -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 +387 -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 +215 -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 +353 -112
- 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
package/meta/menu.js
ADDED
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
2
|
+
const navMain = document.querySelector('nav#nav-main');
|
|
3
|
+
if (!navMain) return;
|
|
4
|
+
|
|
5
|
+
// Load menu data from embedded JSON
|
|
6
|
+
const menuDataScript = document.getElementById('menu-data');
|
|
7
|
+
if (!menuDataScript) return;
|
|
8
|
+
|
|
9
|
+
let menuData;
|
|
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
|
+
// State
|
|
18
|
+
let currentPath = []; // Array of path segments representing current directory
|
|
19
|
+
let expandedLevel1 = new Set(); // Track which level-1 items are expanded
|
|
20
|
+
|
|
21
|
+
// DOM elements
|
|
22
|
+
const breadcrumb = navMain.querySelector('.menu-breadcrumb');
|
|
23
|
+
const backButton = navMain.querySelector('.menu-back');
|
|
24
|
+
const homeButton = navMain.querySelector('.menu-home');
|
|
25
|
+
const currentPathSpan = navMain.querySelector('.menu-current-path');
|
|
26
|
+
const menuContainer = navMain.querySelector('.menu-level');
|
|
27
|
+
|
|
28
|
+
// Helper to check if we're on mobile
|
|
29
|
+
const isMobile = () => window.matchMedia('(max-width: 800px)').matches;
|
|
30
|
+
|
|
31
|
+
// Get items at a specific path
|
|
32
|
+
function getItemsAtPath(path) {
|
|
33
|
+
let items = menuData;
|
|
34
|
+
for (const segment of path) {
|
|
35
|
+
const folder = items.find(item => item.path === (path.slice(0, path.indexOf(segment) + 1).join('/') || segment));
|
|
36
|
+
if (folder && folder.children) {
|
|
37
|
+
items = folder.children;
|
|
38
|
+
} else {
|
|
39
|
+
return [];
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return items;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Find item by path
|
|
46
|
+
function findItemByPath(pathString) {
|
|
47
|
+
const segments = pathString.split('/').filter(Boolean);
|
|
48
|
+
let items = menuData;
|
|
49
|
+
let item = null;
|
|
50
|
+
|
|
51
|
+
for (let i = 0; i < segments.length; i++) {
|
|
52
|
+
const targetPath = segments.slice(0, i + 1).join('/');
|
|
53
|
+
item = items.find(it => it.path === targetPath);
|
|
54
|
+
if (!item) return null;
|
|
55
|
+
if (item.children && i < segments.length - 1) {
|
|
56
|
+
items = item.children;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return item;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Check if current page is within an item's tree
|
|
63
|
+
function isCurrentPageInTree(item) {
|
|
64
|
+
if (isCurrentPage(item)) return true;
|
|
65
|
+
if (item.children) {
|
|
66
|
+
for (const child of item.children) {
|
|
67
|
+
if (isCurrentPageInTree(child)) return true;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Find the current page item within an item's children (level 2)
|
|
74
|
+
function findCurrentPageChild(item) {
|
|
75
|
+
if (!item.children) return null;
|
|
76
|
+
for (const child of item.children) {
|
|
77
|
+
if (isCurrentPage(child)) return child;
|
|
78
|
+
// Also check grandchildren in case current page is deeper
|
|
79
|
+
if (isCurrentPageInTree(child)) return child;
|
|
80
|
+
}
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Track which level-1 item contains the current page (for special collapse behavior)
|
|
85
|
+
let currentPageParentPath = null;
|
|
86
|
+
|
|
87
|
+
// Render menu at current path
|
|
88
|
+
function renderMenu() {
|
|
89
|
+
// Get items for current level (level 1)
|
|
90
|
+
let level1Items;
|
|
91
|
+
if (currentPath.length === 0) {
|
|
92
|
+
level1Items = menuData;
|
|
93
|
+
} else {
|
|
94
|
+
const currentPathString = currentPath.join('/');
|
|
95
|
+
const currentFolder = findItemByPath(currentPathString);
|
|
96
|
+
level1Items = currentFolder?.children || [];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Find which level-1 item contains the current page
|
|
100
|
+
currentPageParentPath = null;
|
|
101
|
+
for (const item of level1Items) {
|
|
102
|
+
if (item.hasChildren && isCurrentPageInTree(item)) {
|
|
103
|
+
currentPageParentPath = item.path;
|
|
104
|
+
break;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Build HTML for level 1 and level 2
|
|
109
|
+
let html = '';
|
|
110
|
+
for (const item of level1Items) {
|
|
111
|
+
const isActive = isCurrentPage(item);
|
|
112
|
+
const activeClass = isActive ? ' current-menu-item' : '';
|
|
113
|
+
const hasChildrenClass = item.hasChildren ? ' has-children' : '';
|
|
114
|
+
|
|
115
|
+
// Level-1 items with children get a caret, not triple-dot
|
|
116
|
+
// Expanded if: manually expanded, or current page is in this tree
|
|
117
|
+
const isExpanded = expandedLevel1.has(item.path) || isCurrentPageInTree(item);
|
|
118
|
+
const expandedClass = isExpanded ? ' expanded' : '';
|
|
119
|
+
const caretIndicator = item.hasChildren
|
|
120
|
+
? `<span class="menu-caret">${isExpanded ? '▼' : '▶'}</span>`
|
|
121
|
+
: '';
|
|
122
|
+
|
|
123
|
+
const labelHtml = item.href
|
|
124
|
+
? `<a href="${item.href}" class="menu-label">${item.label}</a>`
|
|
125
|
+
: `<span class="menu-label">${item.label}</span>`;
|
|
126
|
+
|
|
127
|
+
html += `
|
|
128
|
+
<li class="menu-item level-1${hasChildrenClass}${activeClass}${expandedClass}" data-path="${item.path}">
|
|
129
|
+
<div class="menu-item-row">
|
|
130
|
+
${item.icon}
|
|
131
|
+
${labelHtml}
|
|
132
|
+
${caretIndicator}
|
|
133
|
+
</div>`;
|
|
134
|
+
|
|
135
|
+
// Determine which children to render
|
|
136
|
+
let childrenToRender = [];
|
|
137
|
+
if (item.children && item.children.length > 0) {
|
|
138
|
+
if (isExpanded) {
|
|
139
|
+
// Fully expanded - show all children
|
|
140
|
+
childrenToRender = item.children;
|
|
141
|
+
} else if (item.path === currentPageParentPath) {
|
|
142
|
+
// Collapsed but contains current page - show only current page
|
|
143
|
+
const currentChild = findCurrentPageChild(item);
|
|
144
|
+
if (currentChild) {
|
|
145
|
+
childrenToRender = [currentChild];
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (childrenToRender.length > 0) {
|
|
151
|
+
html += '<ul class="menu-sublevel">';
|
|
152
|
+
for (const child of childrenToRender) {
|
|
153
|
+
const childActive = isCurrentPage(child);
|
|
154
|
+
const childActiveClass = childActive ? ' current-menu-item' : '';
|
|
155
|
+
const childHasChildren = child.hasChildren ? ' has-children' : '';
|
|
156
|
+
// Level-2 items with children get triple-dot
|
|
157
|
+
const childMoreIndicator = child.hasChildren ? '<span class="menu-more" title="Has sub-items">⋮</span>' : '';
|
|
158
|
+
|
|
159
|
+
const childLabelHtml = child.href
|
|
160
|
+
? `<a href="${child.href}" class="menu-label">${child.label}</a>`
|
|
161
|
+
: `<span class="menu-label">${child.label}</span>`;
|
|
162
|
+
|
|
163
|
+
html += `
|
|
164
|
+
<li class="menu-item level-2${childHasChildren}${childActiveClass}" data-path="${child.path}">
|
|
165
|
+
<div class="menu-item-row">
|
|
166
|
+
${child.icon}
|
|
167
|
+
${childLabelHtml}
|
|
168
|
+
${childMoreIndicator}
|
|
169
|
+
</div>
|
|
170
|
+
</li>`;
|
|
171
|
+
}
|
|
172
|
+
html += '</ul>';
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
html += '</li>';
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
menuContainer.innerHTML = html;
|
|
179
|
+
|
|
180
|
+
// Update breadcrumb
|
|
181
|
+
if (currentPath.length > 0) {
|
|
182
|
+
breadcrumb.style.display = 'flex';
|
|
183
|
+
currentPathSpan.textContent = currentPath[currentPath.length - 1];
|
|
184
|
+
} else {
|
|
185
|
+
breadcrumb.style.display = 'none';
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Attach click handlers
|
|
189
|
+
attachClickHandlers();
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Check if an item matches the current page
|
|
193
|
+
function isCurrentPage(item) {
|
|
194
|
+
if (!item.href) return false;
|
|
195
|
+
const currentHref = window.location.pathname;
|
|
196
|
+
// Normalize paths for comparison
|
|
197
|
+
const normalizedItemHref = item.href.replace(/\/index\.html$/, '').replace(/\.html$/, '');
|
|
198
|
+
const normalizedCurrentHref = currentHref.replace(/\/index\.html$/, '').replace(/\.html$/, '');
|
|
199
|
+
return normalizedItemHref === normalizedCurrentHref;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Navigate to a folder
|
|
203
|
+
function navigateToFolder(pathString) {
|
|
204
|
+
currentPath = pathString.split('/').filter(Boolean);
|
|
205
|
+
renderMenu();
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Go back one level
|
|
209
|
+
function goBack() {
|
|
210
|
+
if (currentPath.length > 0) {
|
|
211
|
+
currentPath.pop();
|
|
212
|
+
renderMenu();
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Go to root
|
|
217
|
+
function goToRoot() {
|
|
218
|
+
currentPath = [];
|
|
219
|
+
renderMenu();
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Attach click handlers to menu items
|
|
223
|
+
function attachClickHandlers() {
|
|
224
|
+
const menuItems = menuContainer.querySelectorAll('.menu-item');
|
|
225
|
+
menuItems.forEach(li => {
|
|
226
|
+
const row = li.querySelector('.menu-item-row');
|
|
227
|
+
const caret = li.querySelector('.menu-caret');
|
|
228
|
+
const moreBtn = li.querySelector('.menu-more');
|
|
229
|
+
const link = li.querySelector('a.menu-label');
|
|
230
|
+
const isLevel1 = li.classList.contains('level-1');
|
|
231
|
+
const isLevel2 = li.classList.contains('level-2');
|
|
232
|
+
|
|
233
|
+
if (li.classList.contains('has-children')) {
|
|
234
|
+
if (isLevel1) {
|
|
235
|
+
// Level-1: clicking caret or row (not link) toggles expand/collapse
|
|
236
|
+
const toggleExpand = (e) => {
|
|
237
|
+
e.preventDefault();
|
|
238
|
+
e.stopPropagation();
|
|
239
|
+
const path = li.dataset.path;
|
|
240
|
+
const hasLink = !!link;
|
|
241
|
+
|
|
242
|
+
if (expandedLevel1.has(path)) {
|
|
243
|
+
// Collapsing this item
|
|
244
|
+
expandedLevel1.delete(path);
|
|
245
|
+
} else {
|
|
246
|
+
// Expanding this item
|
|
247
|
+
// If this item has no link (non-navigable folder), collapse others
|
|
248
|
+
if (!hasLink) {
|
|
249
|
+
expandedLevel1.clear();
|
|
250
|
+
}
|
|
251
|
+
expandedLevel1.add(path);
|
|
252
|
+
}
|
|
253
|
+
renderMenu();
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
if (caret) {
|
|
257
|
+
caret.addEventListener('click', toggleExpand);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// If the link points to the current page, clicking it should toggle instead of navigate
|
|
261
|
+
if (link) {
|
|
262
|
+
link.addEventListener('click', (e) => {
|
|
263
|
+
const linkHref = link.getAttribute('href');
|
|
264
|
+
const currentHref = window.location.pathname;
|
|
265
|
+
const normalizedLinkHref = linkHref.replace(/\/index\.html$/, '').replace(/\.html$/, '');
|
|
266
|
+
const normalizedCurrentHref = currentHref.replace(/\/index\.html$/, '').replace(/\.html$/, '');
|
|
267
|
+
|
|
268
|
+
if (normalizedLinkHref === normalizedCurrentHref) {
|
|
269
|
+
// Already on this page - toggle instead of navigate
|
|
270
|
+
toggleExpand(e);
|
|
271
|
+
}
|
|
272
|
+
// Otherwise, let the default navigation happen
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
row.addEventListener('click', (e) => {
|
|
277
|
+
if (!e.target.closest('a')) {
|
|
278
|
+
toggleExpand(e);
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
} else if (isLevel2) {
|
|
282
|
+
// Level-2 with children: clicking ⋮ or row navigates into folder
|
|
283
|
+
if (moreBtn) {
|
|
284
|
+
moreBtn.addEventListener('click', (e) => {
|
|
285
|
+
e.preventDefault();
|
|
286
|
+
e.stopPropagation();
|
|
287
|
+
navigateToFolder(li.dataset.path);
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
row.addEventListener('click', (e) => {
|
|
292
|
+
if (!e.target.closest('a')) {
|
|
293
|
+
e.preventDefault();
|
|
294
|
+
navigateToFolder(li.dataset.path);
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Breadcrumb navigation
|
|
303
|
+
if (backButton) {
|
|
304
|
+
backButton.addEventListener('click', goBack);
|
|
305
|
+
}
|
|
306
|
+
if (homeButton) {
|
|
307
|
+
homeButton.addEventListener('click', goToRoot);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Hamburger menu button toggle
|
|
311
|
+
const menuButton = document.querySelector('button.menu-button');
|
|
312
|
+
if (menuButton) {
|
|
313
|
+
const updateButtonIcon = (isOpen) => {
|
|
314
|
+
menuButton.textContent = isOpen ? '✕' : '☰';
|
|
315
|
+
menuButton.setAttribute('aria-expanded', isOpen);
|
|
316
|
+
menuButton.setAttribute('aria-label', isOpen ? 'Close menu' : 'Open menu');
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
menuButton.addEventListener('click', (e) => {
|
|
320
|
+
e.preventDefault();
|
|
321
|
+
e.stopPropagation();
|
|
322
|
+
|
|
323
|
+
if (isMobile()) {
|
|
324
|
+
navMain.classList.toggle('active');
|
|
325
|
+
const isExpanded = navMain.classList.contains('active');
|
|
326
|
+
updateButtonIcon(isExpanded);
|
|
327
|
+
} else {
|
|
328
|
+
navMain.classList.toggle('collapsed');
|
|
329
|
+
const isExpanded = !navMain.classList.contains('collapsed');
|
|
330
|
+
menuButton.setAttribute('aria-expanded', isExpanded);
|
|
331
|
+
}
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
document.addEventListener('click', (e) => {
|
|
335
|
+
if (isMobile() &&
|
|
336
|
+
navMain.classList.contains('active') &&
|
|
337
|
+
!navMain.contains(e.target) &&
|
|
338
|
+
!menuButton.contains(e.target)) {
|
|
339
|
+
navMain.classList.remove('active');
|
|
340
|
+
updateButtonIcon(false);
|
|
341
|
+
}
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
document.addEventListener('keydown', (e) => {
|
|
345
|
+
if (e.key === 'Escape') {
|
|
346
|
+
if (isMobile() && navMain.classList.contains('active')) {
|
|
347
|
+
navMain.classList.remove('active');
|
|
348
|
+
updateButtonIcon(false);
|
|
349
|
+
menuButton.focus();
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Initialize: find current page and set appropriate path
|
|
356
|
+
function initializeFromCurrentPage() {
|
|
357
|
+
const currentHref = window.location.pathname;
|
|
358
|
+
const pathParts = currentHref.split('/').filter(Boolean);
|
|
359
|
+
|
|
360
|
+
// Try to find the deepest matching folder
|
|
361
|
+
if (pathParts.length > 1) {
|
|
362
|
+
// Navigate to parent folder of current page
|
|
363
|
+
const parentPath = pathParts.slice(0, -1);
|
|
364
|
+
|
|
365
|
+
// Check if this path exists in menu data
|
|
366
|
+
let testPath = [];
|
|
367
|
+
for (const part of parentPath) {
|
|
368
|
+
testPath.push(part);
|
|
369
|
+
const item = findItemByPath(testPath.join('/'));
|
|
370
|
+
if (!item || !item.hasChildren) {
|
|
371
|
+
// Path doesn't exist or isn't a folder, stop here
|
|
372
|
+
testPath.pop();
|
|
373
|
+
break;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// If we found a valid folder path that's more than 1 level deep, navigate there
|
|
378
|
+
if (testPath.length > 1) {
|
|
379
|
+
currentPath = testPath.slice(0, -1); // Go to grandparent so current folder is visible
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
renderMenu();
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
initializeFromCurrentPage();
|
|
387
|
+
});
|
package/meta/search.js
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
// Global search functionality with typeahead
|
|
2
|
+
class GlobalSearch {
|
|
3
|
+
constructor() {
|
|
4
|
+
this.searchInput = document.getElementById('global-search');
|
|
5
|
+
this.searchResults = null;
|
|
6
|
+
this.searchIndex = window.SEARCH_INDEX || [];
|
|
7
|
+
this.currentSelection = -1;
|
|
8
|
+
|
|
9
|
+
if (!this.searchInput) return;
|
|
10
|
+
|
|
11
|
+
this.init();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
init() {
|
|
15
|
+
this.createResultsContainer();
|
|
16
|
+
this.bindEvents();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
createResultsContainer() {
|
|
20
|
+
this.searchResults = document.createElement('div');
|
|
21
|
+
this.searchResults.id = 'search-results';
|
|
22
|
+
this.searchResults.className = 'search-results hidden';
|
|
23
|
+
this.searchInput.parentNode.appendChild(this.searchResults);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
bindEvents() {
|
|
27
|
+
// Input events
|
|
28
|
+
this.searchInput.addEventListener('input', (e) => {
|
|
29
|
+
this.handleSearch(e.target.value);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// Keyboard navigation
|
|
33
|
+
this.searchInput.addEventListener('keydown', (e) => {
|
|
34
|
+
this.handleKeydown(e);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// Focus events
|
|
38
|
+
this.searchInput.addEventListener('focus', () => {
|
|
39
|
+
if (this.searchInput.value.trim()) {
|
|
40
|
+
this.handleSearch(this.searchInput.value);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
this.searchInput.addEventListener('blur', (e) => {
|
|
45
|
+
// Delay hiding to allow click on results
|
|
46
|
+
setTimeout(() => {
|
|
47
|
+
this.hideResults();
|
|
48
|
+
}, 150);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// Click outside to close
|
|
52
|
+
document.addEventListener('click', (e) => {
|
|
53
|
+
if (!this.searchInput.contains(e.target) && !this.searchResults.contains(e.target)) {
|
|
54
|
+
this.hideResults();
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
handleSearch(query) {
|
|
60
|
+
if (!query || query.length < 2) {
|
|
61
|
+
this.hideResults();
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const results = this.search(query);
|
|
66
|
+
this.displayResults(results);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
search(query) {
|
|
70
|
+
const normalizedQuery = query.toLowerCase().trim();
|
|
71
|
+
const results = [];
|
|
72
|
+
|
|
73
|
+
// Search through the index
|
|
74
|
+
this.searchIndex.forEach(item => {
|
|
75
|
+
const titleMatch = item.title.toLowerCase().includes(normalizedQuery);
|
|
76
|
+
const pathMatch = item.path.toLowerCase().includes(normalizedQuery);
|
|
77
|
+
const contentMatch = item.content && item.content.toLowerCase().includes(normalizedQuery);
|
|
78
|
+
|
|
79
|
+
if (titleMatch || pathMatch || contentMatch) {
|
|
80
|
+
let score = 0;
|
|
81
|
+
|
|
82
|
+
// Boost exact title matches
|
|
83
|
+
if (item.title.toLowerCase() === normalizedQuery) score += 100;
|
|
84
|
+
else if (item.title.toLowerCase().startsWith(normalizedQuery)) score += 50;
|
|
85
|
+
else if (titleMatch) score += 25;
|
|
86
|
+
|
|
87
|
+
// Boost path matches
|
|
88
|
+
if (pathMatch) score += 10;
|
|
89
|
+
|
|
90
|
+
// Content matches get lower score
|
|
91
|
+
if (contentMatch) score += 5;
|
|
92
|
+
|
|
93
|
+
results.push({ ...item, score });
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// Sort by score, then by title
|
|
98
|
+
return results
|
|
99
|
+
.sort((a, b) => {
|
|
100
|
+
if (b.score !== a.score) return b.score - a.score;
|
|
101
|
+
return a.title.localeCompare(b.title);
|
|
102
|
+
})
|
|
103
|
+
.slice(0, 10); // Limit to 10 results
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
displayResults(results) {
|
|
107
|
+
if (results.length === 0) {
|
|
108
|
+
this.hideResults();
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
this.searchResults.innerHTML = '';
|
|
113
|
+
this.currentSelection = -1;
|
|
114
|
+
|
|
115
|
+
results.forEach((result, index) => {
|
|
116
|
+
const item = document.createElement('div');
|
|
117
|
+
item.className = 'search-result-item';
|
|
118
|
+
item.dataset.index = index;
|
|
119
|
+
|
|
120
|
+
const title = document.createElement('div');
|
|
121
|
+
title.className = 'search-result-title';
|
|
122
|
+
title.textContent = result.title;
|
|
123
|
+
|
|
124
|
+
const path = document.createElement('div');
|
|
125
|
+
path.className = 'search-result-path';
|
|
126
|
+
path.textContent = result.path;
|
|
127
|
+
|
|
128
|
+
item.appendChild(title);
|
|
129
|
+
item.appendChild(path);
|
|
130
|
+
|
|
131
|
+
// Click handler
|
|
132
|
+
item.addEventListener('click', () => {
|
|
133
|
+
this.navigateToResult(result);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
this.searchResults.appendChild(item);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
this.showResults();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
showResults() {
|
|
143
|
+
this.searchResults.classList.remove('hidden');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
hideResults() {
|
|
147
|
+
this.searchResults.classList.add('hidden');
|
|
148
|
+
this.currentSelection = -1;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
handleKeydown(e) {
|
|
152
|
+
const items = this.searchResults.querySelectorAll('.search-result-item');
|
|
153
|
+
|
|
154
|
+
switch (e.key) {
|
|
155
|
+
case 'ArrowDown':
|
|
156
|
+
e.preventDefault();
|
|
157
|
+
this.currentSelection = Math.min(this.currentSelection + 1, items.length - 1);
|
|
158
|
+
this.updateSelection();
|
|
159
|
+
break;
|
|
160
|
+
|
|
161
|
+
case 'ArrowUp':
|
|
162
|
+
e.preventDefault();
|
|
163
|
+
this.currentSelection = Math.max(this.currentSelection - 1, -1);
|
|
164
|
+
this.updateSelection();
|
|
165
|
+
break;
|
|
166
|
+
|
|
167
|
+
case 'Enter':
|
|
168
|
+
e.preventDefault();
|
|
169
|
+
if (this.currentSelection >= 0 && items[this.currentSelection]) {
|
|
170
|
+
const index = items[this.currentSelection].dataset.index;
|
|
171
|
+
const results = this.getLastSearchResults();
|
|
172
|
+
if (results && results[index]) {
|
|
173
|
+
this.navigateToResult(results[index]);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
break;
|
|
177
|
+
|
|
178
|
+
case 'Escape':
|
|
179
|
+
this.hideResults();
|
|
180
|
+
this.searchInput.blur();
|
|
181
|
+
break;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
updateSelection() {
|
|
186
|
+
const items = this.searchResults.querySelectorAll('.search-result-item');
|
|
187
|
+
items.forEach((item, index) => {
|
|
188
|
+
item.classList.toggle('selected', index === this.currentSelection);
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
getLastSearchResults() {
|
|
193
|
+
// Store last search results for keyboard navigation
|
|
194
|
+
if (!this._lastResults) {
|
|
195
|
+
this._lastResults = this.search(this.searchInput.value);
|
|
196
|
+
}
|
|
197
|
+
return this._lastResults;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
navigateToResult(result) {
|
|
201
|
+
window.location.href = result.url;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Initialize when DOM is loaded
|
|
206
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
207
|
+
new GlobalSearch();
|
|
208
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
2
|
+
const article = document.querySelector('article#main-content');
|
|
3
|
+
if (!article) return;
|
|
4
|
+
|
|
5
|
+
const children = Array.from(article.children);
|
|
6
|
+
let sections = [];
|
|
7
|
+
let currentSection = document.createElement('section');
|
|
8
|
+
currentSection.classList.add('sectionOuter');
|
|
9
|
+
|
|
10
|
+
for (let i = 0; i < children.length; i++) {
|
|
11
|
+
const el = children[i];
|
|
12
|
+
if (el.tagName === 'H1' && currentSection.childNodes.length > 0) {
|
|
13
|
+
sections.push(currentSection);
|
|
14
|
+
currentSection = document.createElement('section');
|
|
15
|
+
currentSection.classList.add('sectionOuter');
|
|
16
|
+
}
|
|
17
|
+
currentSection.appendChild(el);
|
|
18
|
+
}
|
|
19
|
+
if (currentSection.childNodes.length > 0) {
|
|
20
|
+
sections.push(currentSection);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Remove all existing children
|
|
24
|
+
while (article.firstChild) {
|
|
25
|
+
article.removeChild(article.firstChild);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Append new sections
|
|
29
|
+
sections.forEach(section => article.appendChild(section));
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
// Optional: Add section numbers or other decorations
|
|
33
|
+
Array.from(article.querySelectorAll('section.sectionOuter')).forEach((section, index) => {
|
|
34
|
+
section.style.setProperty('--section-index', index + 1);
|
|
35
|
+
});
|
|
36
|
+
});
|