@kenjura/ursa 0.75.0 → 0.77.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.
Files changed (36) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/meta/default.css +149 -3
  3. package/meta/templates/default-template/default.css +1268 -0
  4. package/meta/{default-template.html → templates/default-template/index.html} +49 -2
  5. package/meta/{menu.js → templates/default-template/menu.js} +1 -1
  6. package/meta/templates/default-template/sectionify.js +46 -0
  7. package/meta/templates/default-template/widgets.js +701 -0
  8. package/package.json +1 -1
  9. package/src/dev.js +125 -34
  10. package/src/helper/assetBundler.js +471 -0
  11. package/src/helper/build/autoIndex.js +26 -23
  12. package/src/helper/build/cacheBust.js +79 -0
  13. package/src/helper/build/navCache.js +4 -0
  14. package/src/helper/build/templates.js +176 -19
  15. package/src/helper/build/watchCache.js +7 -0
  16. package/src/helper/customMenu.js +4 -2
  17. package/src/helper/dependencyTracker.js +269 -0
  18. package/src/helper/findScriptJs.js +29 -0
  19. package/src/helper/findStyleCss.js +29 -0
  20. package/src/helper/portUtils.js +132 -0
  21. package/src/jobs/generate.js +276 -59
  22. package/src/serve.js +446 -162
  23. package/meta/character-sheet.css +0 -50
  24. package/meta/widgets.js +0 -376
  25. /package/meta/{goudy_bookletter_1911-webfont.woff → shared/goudy_bookletter_1911-webfont.woff} +0 -0
  26. /package/meta/{character-sheet/css → templates/character-sheet-template}/character-sheet.css +0 -0
  27. /package/meta/{character-sheet/js → templates/character-sheet-template}/components.js +0 -0
  28. /package/meta/{cssui.bundle.min.css → templates/character-sheet-template/cssui.bundle.min.css} +0 -0
  29. /package/meta/{character-sheet-template.html → templates/character-sheet-template/index.html} +0 -0
  30. /package/meta/{character-sheet/js → templates/character-sheet-template}/main.js +0 -0
  31. /package/meta/{character-sheet/js → templates/character-sheet-template}/model.js +0 -0
  32. /package/meta/{search.js → templates/default-template/search.js} +0 -0
  33. /package/meta/{sticky.js → templates/default-template/sticky.js} +0 -0
  34. /package/meta/{toc-generator.js → templates/default-template/toc-generator.js} +0 -0
  35. /package/meta/{toc.js → templates/default-template/toc.js} +0 -0
  36. /package/meta/{template2.html → templates/template2/index.html} +0 -0
@@ -0,0 +1,701 @@
1
+ /**
2
+ * Widget system for the top nav panel.
3
+ *
4
+ * Widgets appear as icon buttons in the nav bar. Clicking a button toggles a
5
+ * dropdown panel below the nav. Left-side and right-side widgets have separate
6
+ * dropdown panels. One widget can be open per side at a time.
7
+ *
8
+ * Widget state (open/closed) is persisted in localStorage so it survives page reloads.
9
+ *
10
+ * Built-in widgets:
11
+ * Left: Recent Activity (open by default)
12
+ * Right: TOC, Search, Profile
13
+ */
14
+ class WidgetManager {
15
+ constructor() {
16
+ this.dropdownRight = document.getElementById('widget-dropdown');
17
+ this.dropdownLeft = document.getElementById('widget-dropdown-left');
18
+ this.buttons = document.querySelectorAll('.widget-button[data-widget]');
19
+ this.activeRight = null;
20
+ this.activeLeft = null;
21
+
22
+ // Widgets that default to open on first visit
23
+ this.defaultOpen = new Set(['recent-activity']);
24
+
25
+ if (this.buttons.length === 0) return;
26
+
27
+ this.init();
28
+ }
29
+
30
+ /**
31
+ * Get the side (left/right) for a widget based on its button's data-widget-side attribute
32
+ */
33
+ getSide(widgetName) {
34
+ const btn = document.querySelector(`.widget-button[data-widget="${widgetName}"]`);
35
+ return btn?.dataset.widgetSide === 'left' ? 'left' : 'right';
36
+ }
37
+
38
+ /**
39
+ * Get the dropdown element for a given side
40
+ */
41
+ getDropdown(side) {
42
+ return side === 'left' ? this.dropdownLeft : this.dropdownRight;
43
+ }
44
+
45
+ /**
46
+ * Get the active widget name for a given side
47
+ */
48
+ getActive(side) {
49
+ return side === 'left' ? this.activeLeft : this.activeRight;
50
+ }
51
+
52
+ /**
53
+ * Set the active widget name for a given side
54
+ */
55
+ setActive(side, widgetName) {
56
+ if (side === 'left') {
57
+ this.activeLeft = widgetName;
58
+ } else {
59
+ this.activeRight = widgetName;
60
+ }
61
+ }
62
+
63
+ init() {
64
+ // Bind button clicks
65
+ this.buttons.forEach(btn => {
66
+ btn.addEventListener('click', (e) => {
67
+ e.stopPropagation();
68
+ const widgetName = btn.dataset.widget;
69
+ this.toggle(widgetName);
70
+ });
71
+ });
72
+
73
+ // Bind close buttons inside widget headers
74
+ document.querySelectorAll('.widget-close-btn').forEach(closeBtn => {
75
+ closeBtn.addEventListener('click', (e) => {
76
+ e.stopPropagation();
77
+ const widgetContent = closeBtn.closest('.widget-content');
78
+ if (widgetContent) {
79
+ const widgetName = widgetContent.dataset.widget;
80
+ this.close(this.getSide(widgetName));
81
+ }
82
+ });
83
+ });
84
+
85
+ // Close on outside click
86
+ document.addEventListener('click', (e) => {
87
+ // Close right-side widget if click is outside
88
+ if (this.activeRight && this.dropdownRight &&
89
+ !this.dropdownRight.contains(e.target) &&
90
+ !e.target.closest('.widget-button')) {
91
+ this.close('right');
92
+ }
93
+ // Close left-side widget if click is outside
94
+ if (this.activeLeft && this.dropdownLeft &&
95
+ !this.dropdownLeft.contains(e.target) &&
96
+ !e.target.closest('.widget-button')) {
97
+ this.close('left');
98
+ }
99
+ });
100
+
101
+ // Close on Escape
102
+ document.addEventListener('keydown', (e) => {
103
+ if (e.key === 'Escape') {
104
+ if (this.activeRight) this.close('right');
105
+ if (this.activeLeft) this.close('left');
106
+ }
107
+ });
108
+
109
+ // Initialize search widget content
110
+ this.initSearchWidget();
111
+
112
+ // Initialize recent activity widget
113
+ this.initRecentActivityWidget();
114
+
115
+ // Track current page view and initialize suggested content widget
116
+ this.trackPageView();
117
+ this.initSuggestedWidget();
118
+
119
+ // Restore saved widget states from localStorage
120
+ this.restoreState();
121
+ }
122
+
123
+ /**
124
+ * Save widget open/closed state to localStorage
125
+ */
126
+ saveState(widgetName, isOpen) {
127
+ try {
128
+ const key = `ursa-widget-${widgetName}`;
129
+ localStorage.setItem(key, isOpen ? 'open' : 'closed');
130
+ } catch (e) { /* localStorage not available */ }
131
+ }
132
+
133
+ /**
134
+ * Restore widget states from localStorage.
135
+ * For widgets with no saved state, use their default (defaultOpen set).
136
+ */
137
+ restoreState() {
138
+ // Gather all widget names
139
+ const widgetNames = new Set();
140
+ this.buttons.forEach(btn => widgetNames.add(btn.dataset.widget));
141
+
142
+ for (const widgetName of widgetNames) {
143
+ const key = `ursa-widget-${widgetName}`;
144
+ let saved;
145
+ try {
146
+ saved = localStorage.getItem(key);
147
+ } catch (e) { /* localStorage not available */ }
148
+
149
+ const shouldOpen = saved === 'open' || (saved === null && this.defaultOpen.has(widgetName));
150
+ if (shouldOpen) {
151
+ this.open(widgetName);
152
+ }
153
+ }
154
+ }
155
+
156
+ /**
157
+ * Toggle a widget open/closed.
158
+ */
159
+ toggle(widgetName) {
160
+ const side = this.getSide(widgetName);
161
+ if (this.getActive(side) === widgetName) {
162
+ this.close(side);
163
+ return;
164
+ }
165
+
166
+ this.open(widgetName);
167
+ }
168
+
169
+ /**
170
+ * Open a specific widget panel.
171
+ */
172
+ open(widgetName) {
173
+ const side = this.getSide(widgetName);
174
+ const dropdown = this.getDropdown(side);
175
+ if (!dropdown) return;
176
+
177
+ // Close any open widget on the same side first
178
+ const currentActive = this.getActive(side);
179
+ if (currentActive) {
180
+ this.deactivateContent(currentActive);
181
+ // Save the closed widget's state
182
+ this.saveState(currentActive, false);
183
+ }
184
+
185
+ this.setActive(side, widgetName);
186
+
187
+ // Show dropdown
188
+ dropdown.classList.remove('hidden');
189
+ dropdown.dataset.activeWidget = widgetName;
190
+
191
+ // Show the correct content panel
192
+ this.activateContent(widgetName);
193
+
194
+ // Update button states (only for this side's buttons)
195
+ this.buttons.forEach(btn => {
196
+ if (this.getSide(btn.dataset.widget) === side) {
197
+ btn.classList.toggle('active', btn.dataset.widget === widgetName);
198
+ }
199
+ });
200
+
201
+ // Save state
202
+ this.saveState(widgetName, true);
203
+
204
+ // Fire event for other scripts to listen to
205
+ document.dispatchEvent(new CustomEvent('widget-opened', { detail: { widget: widgetName, side } }));
206
+ }
207
+
208
+ /**
209
+ * Close the currently open widget on a given side.
210
+ */
211
+ close(side) {
212
+ const active = this.getActive(side);
213
+ if (!active) return;
214
+
215
+ const dropdown = this.getDropdown(side);
216
+ this.deactivateContent(active);
217
+
218
+ // Save state
219
+ this.saveState(active, false);
220
+
221
+ this.setActive(side, null);
222
+ if (dropdown) {
223
+ dropdown.classList.add('hidden');
224
+ delete dropdown.dataset.activeWidget;
225
+ }
226
+
227
+ // Update button states for this side
228
+ this.buttons.forEach(btn => {
229
+ if (this.getSide(btn.dataset.widget) === side) {
230
+ btn.classList.remove('active');
231
+ }
232
+ });
233
+
234
+ // Fire event
235
+ document.dispatchEvent(new CustomEvent('widget-closed', { detail: { widget: active, side } }));
236
+ }
237
+
238
+ /**
239
+ * Show a widget's content panel.
240
+ */
241
+ activateContent(widgetName) {
242
+ const side = this.getSide(widgetName);
243
+ const dropdown = this.getDropdown(side);
244
+ if (!dropdown) return;
245
+
246
+ const content = dropdown.querySelector(`.widget-content[data-widget="${widgetName}"]`);
247
+ if (content) {
248
+ content.classList.add('active');
249
+ }
250
+
251
+ // Widget-specific activation
252
+ if (widgetName === 'search') {
253
+ this.activateSearch();
254
+ }
255
+ }
256
+
257
+ /**
258
+ * Hide a widget's content panel.
259
+ */
260
+ deactivateContent(widgetName) {
261
+ const side = this.getSide(widgetName);
262
+ const dropdown = this.getDropdown(side);
263
+ if (!dropdown) return;
264
+
265
+ const content = dropdown.querySelector(`.widget-content[data-widget="${widgetName}"]`);
266
+ if (content) {
267
+ content.classList.remove('active');
268
+ }
269
+
270
+ // Widget-specific deactivation
271
+ if (widgetName === 'search') {
272
+ this.deactivateSearch();
273
+ }
274
+ }
275
+
276
+ /**
277
+ * Initialize search widget — move the search input and results into the widget panel.
278
+ */
279
+ initSearchWidget() {
280
+ const searchContent = document.getElementById('widget-content-search');
281
+ if (!searchContent) return;
282
+
283
+ // The search input and wrapper are created by search.js (GlobalSearch).
284
+ // We need to wait for it to be ready, then move elements into the widget.
285
+ // Use a short delay to let GlobalSearch initialize first.
286
+ const moveSearch = () => {
287
+ const searchWrapper = document.querySelector('.search-wrapper-inline');
288
+ const searchResults = document.getElementById('search-results');
289
+
290
+ if (searchWrapper) {
291
+ // Clone the search input into the widget (the inline one stays for non-top-menu/mobile)
292
+ // Actually, we'll relocate the existing elements when the widget is activated.
293
+ // For now, create a dedicated search input for the widget.
294
+ const widgetInput = document.createElement('input');
295
+ widgetInput.id = 'widget-search-input';
296
+ widgetInput.type = 'text';
297
+ widgetInput.placeholder = 'Search...';
298
+ widgetInput.className = 'widget-search-input';
299
+
300
+ const widgetWrapper = document.createElement('div');
301
+ widgetWrapper.className = 'widget-search-wrapper';
302
+ widgetWrapper.appendChild(widgetInput);
303
+
304
+ // Create dedicated results container for widget
305
+ const widgetResults = document.createElement('div');
306
+ widgetResults.id = 'widget-search-results';
307
+ widgetResults.className = 'widget-search-results';
308
+
309
+ searchContent.appendChild(widgetWrapper);
310
+ searchContent.appendChild(widgetResults);
311
+
312
+ // Bind the widget search input to the GlobalSearch instance
313
+ this.bindWidgetSearch(widgetInput, widgetResults);
314
+ }
315
+ };
316
+
317
+ // Wait for search.js to initialize
318
+ setTimeout(moveSearch, 50);
319
+ }
320
+
321
+ /**
322
+ * Bind the widget search input to use GlobalSearch's search functionality.
323
+ */
324
+ bindWidgetSearch(input, resultsContainer) {
325
+ this._widgetSearchInput = input;
326
+ this._widgetSearchResults = resultsContainer;
327
+
328
+ let currentSelection = -1;
329
+
330
+ input.addEventListener('input', () => {
331
+ const query = input.value.trim();
332
+ this.performWidgetSearch(query);
333
+ });
334
+
335
+ input.addEventListener('keydown', (e) => {
336
+ const items = resultsContainer.querySelectorAll('.search-result-item');
337
+
338
+ switch (e.key) {
339
+ case 'ArrowDown':
340
+ e.preventDefault();
341
+ if (items.length > 0) {
342
+ currentSelection = Math.min(currentSelection + 1, items.length - 1);
343
+ this.updateWidgetSearchSelection(items, currentSelection);
344
+ }
345
+ break;
346
+ case 'ArrowUp':
347
+ e.preventDefault();
348
+ if (items.length > 0) {
349
+ currentSelection = Math.max(currentSelection - 1, 0);
350
+ this.updateWidgetSearchSelection(items, currentSelection);
351
+ }
352
+ break;
353
+ case 'Enter':
354
+ e.preventDefault();
355
+ if (currentSelection >= 0 && items[currentSelection]) {
356
+ items[currentSelection].click();
357
+ }
358
+ break;
359
+ case 'Escape':
360
+ this.close();
361
+ break;
362
+ }
363
+ });
364
+
365
+ // Reset selection on new search
366
+ input.addEventListener('input', () => { currentSelection = -1; });
367
+ }
368
+
369
+ /**
370
+ * Perform search using GlobalSearch's search logic, rendering into widget results.
371
+ */
372
+ performWidgetSearch(query) {
373
+ const gs = window.globalSearch;
374
+ const container = this._widgetSearchResults;
375
+ if (!gs || !container) return;
376
+
377
+ container.innerHTML = '';
378
+
379
+ if (!query || query.length < gs.MIN_QUERY_LENGTH) {
380
+ if (query && query.length > 0) {
381
+ container.innerHTML = `<div class="search-result-message">Type at least ${gs.MIN_QUERY_LENGTH} characters to search</div>`;
382
+ }
383
+ return;
384
+ }
385
+
386
+ if (!gs.indexLoaded) {
387
+ container.innerHTML = '<div class="search-result-message">Loading search index...</div>';
388
+ return;
389
+ }
390
+
391
+ const pathResults = gs.searchPaths(query);
392
+ const fullTextResults = gs.searchFullText(query);
393
+
394
+ // Deduplicate
395
+ const pathPaths = new Set(pathResults.map(r => r.path));
396
+ const uniqueFullTextResults = fullTextResults.filter(r => !pathPaths.has(r.path));
397
+
398
+ if (pathResults.length === 0 && uniqueFullTextResults.length === 0) {
399
+ container.innerHTML = `<div class="search-result-message">No results for "${query}"</div>`;
400
+ return;
401
+ }
402
+
403
+ // Path results section
404
+ if (pathResults.length > 0) {
405
+ const section = document.createElement('div');
406
+ section.className = 'search-section';
407
+ const header = document.createElement('div');
408
+ header.className = 'search-section-header';
409
+ header.textContent = `Title/Path Matches (${pathResults.length})`;
410
+ section.appendChild(header);
411
+
412
+ const limit = Math.min(pathResults.length, 10);
413
+ for (let i = 0; i < limit; i++) {
414
+ section.appendChild(this.createWidgetResultItem(pathResults[i]));
415
+ }
416
+ if (pathResults.length > 10) {
417
+ const more = document.createElement('div');
418
+ more.className = 'search-result-message';
419
+ more.textContent = `... and ${pathResults.length - 10} more`;
420
+ section.appendChild(more);
421
+ }
422
+ container.appendChild(section);
423
+ }
424
+
425
+ // Full-text results section
426
+ if (uniqueFullTextResults.length > 0) {
427
+ const section = document.createElement('div');
428
+ section.className = 'search-section';
429
+ const header = document.createElement('div');
430
+ header.className = 'search-section-header';
431
+ header.textContent = `Content Matches (${uniqueFullTextResults.length})`;
432
+ section.appendChild(header);
433
+
434
+ const limit = Math.min(uniqueFullTextResults.length, 10);
435
+ for (let i = 0; i < limit; i++) {
436
+ section.appendChild(this.createWidgetResultItem(uniqueFullTextResults[i]));
437
+ }
438
+ if (uniqueFullTextResults.length > 10) {
439
+ const more = document.createElement('div');
440
+ more.className = 'search-result-message';
441
+ more.textContent = `... and ${uniqueFullTextResults.length - 10} more`;
442
+ section.appendChild(more);
443
+ }
444
+ container.appendChild(section);
445
+ }
446
+ }
447
+
448
+ /**
449
+ * Create a search result item for the widget.
450
+ */
451
+ createWidgetResultItem(result) {
452
+ const item = document.createElement('div');
453
+ item.className = 'search-result-item';
454
+
455
+ const title = document.createElement('div');
456
+ title.className = 'search-result-title';
457
+ title.textContent = result.title || 'Untitled';
458
+
459
+ const path = document.createElement('div');
460
+ path.className = 'search-result-path';
461
+ path.textContent = result.path || result.url || '';
462
+
463
+ item.appendChild(title);
464
+ item.appendChild(path);
465
+
466
+ item.addEventListener('click', () => {
467
+ window.location.href = result.url || result.path;
468
+ });
469
+
470
+ item.addEventListener('mouseenter', () => {
471
+ // Clear other selections
472
+ item.closest('.widget-search-results')?.querySelectorAll('.search-result-item').forEach(el => {
473
+ el.classList.remove('selected');
474
+ });
475
+ item.classList.add('selected');
476
+ });
477
+
478
+ return item;
479
+ }
480
+
481
+ updateWidgetSearchSelection(items, index) {
482
+ items.forEach((item, i) => {
483
+ item.classList.toggle('selected', i === index);
484
+ });
485
+ if (index >= 0 && items[index]) {
486
+ items[index].scrollIntoView({ block: 'nearest' });
487
+ }
488
+ }
489
+
490
+ /**
491
+ * Called when the search widget is activated.
492
+ */
493
+ activateSearch() {
494
+ const input = this._widgetSearchInput;
495
+ if (input) {
496
+ // Focus with small delay to allow panel animation
497
+ setTimeout(() => input.focus(), 50);
498
+ }
499
+ }
500
+
501
+ /**
502
+ * Called when the search widget is deactivated.
503
+ */
504
+ deactivateSearch() {
505
+ // Keep the search query so user can re-open and see results
506
+ }
507
+
508
+ /**
509
+ * Initialize the Recent Activity widget — fetch data and render the list.
510
+ */
511
+ initRecentActivityWidget() {
512
+ const container = document.querySelector('.recent-activity-list');
513
+ if (!container) return;
514
+
515
+ container.innerHTML = '<div class="recent-activity-loading">Loading...</div>';
516
+
517
+ fetch('/public/recent-activity.json')
518
+ .then(res => {
519
+ if (!res.ok) throw new Error('Not found');
520
+ return res.json();
521
+ })
522
+ .then(items => {
523
+ container.innerHTML = '';
524
+ if (!items || items.length === 0) {
525
+ container.innerHTML = '<div class="recent-activity-empty">No recent activity</div>';
526
+ return;
527
+ }
528
+ const ul = document.createElement('ul');
529
+ ul.className = 'recent-activity-items';
530
+ for (const item of items) {
531
+ const li = document.createElement('li');
532
+ li.className = 'recent-activity-item';
533
+ const a = document.createElement('a');
534
+ a.href = item.url;
535
+ a.textContent = item.title || 'Untitled';
536
+ a.className = 'recent-activity-link';
537
+ const time = document.createElement('span');
538
+ time.className = 'recent-activity-time';
539
+ time.textContent = this.formatRelativeTime(item.mtime);
540
+ time.title = new Date(item.mtime).toLocaleString();
541
+ li.appendChild(a);
542
+ li.appendChild(time);
543
+ ul.appendChild(li);
544
+ }
545
+ container.appendChild(ul);
546
+ })
547
+ .catch(() => {
548
+ container.innerHTML = '<div class="recent-activity-empty">Recent activity unavailable</div>';
549
+ });
550
+ }
551
+
552
+ /**
553
+ * Format a timestamp into a human-readable relative time string.
554
+ */
555
+ formatRelativeTime(mtimeMs) {
556
+ const now = Date.now();
557
+ const diff = now - mtimeMs;
558
+ const seconds = Math.floor(diff / 1000);
559
+ const minutes = Math.floor(seconds / 60);
560
+ const hours = Math.floor(minutes / 60);
561
+ const days = Math.floor(hours / 24);
562
+ const weeks = Math.floor(days / 7);
563
+ const months = Math.floor(days / 30);
564
+ const years = Math.floor(days / 365);
565
+
566
+ if (seconds < 60) return 'just now';
567
+ if (minutes < 60) return `${minutes}m ago`;
568
+ if (hours < 24) return `${hours}h ago`;
569
+ if (days < 7) return `${days}d ago`;
570
+ if (weeks < 5) return `${weeks}w ago`;
571
+ if (months < 12) return `${months}mo ago`;
572
+ return `${years}y ago`;
573
+ }
574
+
575
+ /**
576
+ * Track current page view in localStorage.
577
+ * Stores a map of URL → { count, lastVisit, title }
578
+ */
579
+ trackPageView() {
580
+ const url = window.location.pathname;
581
+ // Skip tracking for index/home pages to keep suggestions more focused
582
+ if (url === '/' || url === '/index.html') return;
583
+
584
+ const STORAGE_KEY = 'ursa-page-views';
585
+ const MAX_TRACKED_PAGES = 100; // Limit storage size
586
+
587
+ try {
588
+ let pageViews = {};
589
+ const stored = localStorage.getItem(STORAGE_KEY);
590
+ if (stored) {
591
+ pageViews = JSON.parse(stored);
592
+ }
593
+
594
+ // Get page title from the document
595
+ const title = document.title || url;
596
+
597
+ // Update or create entry for this page
598
+ if (pageViews[url]) {
599
+ pageViews[url].count += 1;
600
+ pageViews[url].lastVisit = Date.now();
601
+ pageViews[url].title = title;
602
+ } else {
603
+ pageViews[url] = {
604
+ count: 1,
605
+ lastVisit: Date.now(),
606
+ title: title
607
+ };
608
+ }
609
+
610
+ // Prune oldest entries if we exceed the limit
611
+ const entries = Object.entries(pageViews);
612
+ if (entries.length > MAX_TRACKED_PAGES) {
613
+ // Sort by lastVisit and keep only the most recent
614
+ entries.sort((a, b) => b[1].lastVisit - a[1].lastVisit);
615
+ pageViews = Object.fromEntries(entries.slice(0, MAX_TRACKED_PAGES));
616
+ }
617
+
618
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(pageViews));
619
+ } catch (e) {
620
+ // localStorage not available or quota exceeded
621
+ }
622
+ }
623
+
624
+ /**
625
+ * Initialize the Suggested Content widget.
626
+ * Shows frequently viewed pages based on localStorage tracking.
627
+ */
628
+ initSuggestedWidget() {
629
+ const container = document.querySelector('.suggested-content-list');
630
+ if (!container) return;
631
+
632
+ const STORAGE_KEY = 'ursa-page-views';
633
+ const MAX_SUGGESTIONS = 10;
634
+ const currentUrl = window.location.pathname;
635
+
636
+ try {
637
+ const stored = localStorage.getItem(STORAGE_KEY);
638
+ if (!stored) {
639
+ container.innerHTML = '<div class="suggested-empty">Visit more pages to see suggestions</div>';
640
+ return;
641
+ }
642
+
643
+ const pageViews = JSON.parse(stored);
644
+ const entries = Object.entries(pageViews);
645
+
646
+ if (entries.length === 0) {
647
+ container.innerHTML = '<div class="suggested-empty">Visit more pages to see suggestions</div>';
648
+ return;
649
+ }
650
+
651
+ // Filter out current page and sort by view count (descending)
652
+ const sorted = entries
653
+ .filter(([url]) => url !== currentUrl)
654
+ .sort((a, b) => {
655
+ // Primary sort: view count (descending)
656
+ const countDiff = b[1].count - a[1].count;
657
+ if (countDiff !== 0) return countDiff;
658
+ // Secondary sort: last visit (descending)
659
+ return b[1].lastVisit - a[1].lastVisit;
660
+ })
661
+ .slice(0, MAX_SUGGESTIONS);
662
+
663
+ if (sorted.length === 0) {
664
+ container.innerHTML = '<div class="suggested-empty">Visit more pages to see suggestions</div>';
665
+ return;
666
+ }
667
+
668
+ container.innerHTML = '';
669
+ const ul = document.createElement('ul');
670
+ ul.className = 'suggested-items';
671
+
672
+ for (const [url, data] of sorted) {
673
+ const li = document.createElement('li');
674
+ li.className = 'suggested-item';
675
+
676
+ const a = document.createElement('a');
677
+ a.href = url;
678
+ a.className = 'suggested-link';
679
+ a.textContent = data.title || url;
680
+
681
+ const meta = document.createElement('span');
682
+ meta.className = 'suggested-meta';
683
+ meta.textContent = `${data.count} view${data.count !== 1 ? 's' : ''}`;
684
+ meta.title = `Last visited: ${new Date(data.lastVisit).toLocaleString()}`;
685
+
686
+ li.appendChild(a);
687
+ li.appendChild(meta);
688
+ ul.appendChild(li);
689
+ }
690
+
691
+ container.appendChild(ul);
692
+ } catch (e) {
693
+ container.innerHTML = '<div class="suggested-empty">Unable to load suggestions</div>';
694
+ }
695
+ }
696
+ }
697
+
698
+ // Initialize widgets when DOM is ready
699
+ document.addEventListener('DOMContentLoaded', () => {
700
+ window.widgetManager = new WidgetManager();
701
+ });
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.75.0",
5
+ "version": "0.77.0",
6
6
  "description": "static site generator from MD/wikitext/YML",
7
7
  "main": "lib/index.js",
8
8
  "bin": {