@kenjura/ursa 0.75.0 → 0.76.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 CHANGED
@@ -1,3 +1,70 @@
1
+ # 0.77.0 (TODO)
2
+
3
+ QOL:
4
+ - When 'serve' encounters an occupied port 8080, prompt the user to find an available port instead of just exiting with an error. Will check open ports and find the closest port to 8080, then ask the user if they want to use it.
5
+
6
+ Static assets revamp:
7
+ - Meta
8
+ - All scripts and stylesheets referenced by a template should be bundled together into a single CSS file and a single JS file per template. Use esbuild or similar for bundling and minification. This will reduce the number of requests and ensure that all template assets are loaded together.
9
+ - Documents
10
+ - style.css and script.js files should be external
11
+ - in serve mode, a document can have multiple style.css and script.js from multiple levels; this should be separate tags so individual ones can be invalidated
12
+ - in generate mode, these should be bundled together into a single CSS file and a single JS file per folder
13
+ - why folder? well, /foo may have style.css and script.js, and /foo/bar may have its own style.css and script.js. Every document in foo/bar includes both scripts and both stylesheets, but documents in foo only include the foo ones.
14
+ - it is true that foo.bundle.js and foo-bar.bundle.js will duplicate code (foo-bar is a superset of foo), but the point is that every page load has the minimum number of requests (1 CSS and 1 JS)
15
+ - future optimization: bundle document and meta scripts/styles together. This sounds complicated compared to the expected return
16
+ - Bundling logic:
17
+ - In serve mode, minification without obfuscation is fine, as serve is often used to debug stylesheets and scripts.
18
+ - In generate mode, we can do full bundling and minification for optimal performance. Map files can be generated for debugging if needed.
19
+
20
+ Regeneration revamp:
21
+ - Existing logic:
22
+ - On first generation, save a cache of document output given some sort of hash of the source file and metadata (e.g. mtime, size, etc.)
23
+ - All static files (meta and document) should include a datetime or hash-based cache-buster in their query strings / filenames, so they can be invalidated as needed
24
+ - On subsequent generations, if the source file's hash is unchanged, skip regeneration and reuse the existing output file. (Note: this doesn't handle cases where the statis files changed and the document didn't; see below)
25
+ - Push a notification to the client when a file is regenerated, so the client can update the page if it's currently being viewed
26
+ - New logic is as above, plus: (some of this is partially complete, but these are the complete requirements)
27
+ - When any file being watched is changed, determine the list of affected files. For instance:
28
+ - A normal document will obviously invalidate that exact document.
29
+ - Special Ursa static files (menu.md, style.css, and script.js) are inherited by all documents in the current folder and all subfolders, so they will invalidate all documents in the current folder and all subfolders.
30
+ - Meta static files:
31
+ - A template file in meta will invalidate all documents that use that template.
32
+ - A stylesheet or script file in meta will invalidate all documents that inherit from that meta (which is probably everything).
33
+ - All other static files in the docroot (assuming they're linked at all by any document) should be invalidated thus:
34
+ - Calculate a new hash for the static file
35
+ - Find all documents that reference that static file
36
+ - Regenerate the html (even if the source md/mdx/txt file is unchanged) for those documents to update the cache-busting query string for the static file reference
37
+ - This should catch all the various edge cases that previously required restarting the server or doing a full regeneration.
38
+
39
+
40
+ Top Menu improvements:
41
+ - When a submenu overflows the available viewport height, it should become scrollable instead of overflowing off the screen. This can be achieved with CSS by setting a max-height and overflow-y: auto on the submenu container.
42
+
43
+ New Widgets:
44
+ - Suggested Content
45
+ - A new left-side widget that shows a list of suggested content based on the current page. Categories of suggested content:
46
+ - Content you frequently view (uses localStorage to track page views and show most viewed content)
47
+ - Future ideas:
48
+ - LLM-guided suggestions based on frequently viewed content, suggested related documents you haven't viewed yet, etc.
49
+
50
+ Bugs:
51
+ - [ ] When using menu.md with auto-generation, the top menu's Home href is "//index.html" instead of "/index.html". On localhost, this ends up working fine, but on https://realdomain.com, this loads https://index.html which obviously doesn't work. The current logic seems to prefer absolute URLs, so in this case, the url for home should be "/index.html" (not double slash).
52
+ - [ ] Site style.css is not present on auto-generated index pages
53
+ - Regeneration issues:
54
+ - Create a power, that power page now exists. But powers.json doesn't have it.
55
+
56
+ # 0.76.0
57
+ 2026-02-11
58
+
59
+ - **New Feature: Recent Activity widget.** A new topbar widget shows the 10 most recently modified documents in the docroot, sorted by modification date (most recent first). The widget appears on the left side of the top nav (to the right of the home icon) and is open by default.
60
+ - Recent activity data is collected during the generate phase by stat-ing each article file, then written to `public/recent-activity.json`.
61
+ - In serve/dev mode, the recent activity list is built during background cache initialization and updated live when article files are changed.
62
+ - The single-file regeneration path (`regenerateSingleFile`) also updates the recent activity JSON incrementally.
63
+ - **Widget system improvements:**
64
+ - All widgets now have a close (✕) button in the upper-right corner of their panel header. Clicking it closes the widget and deselects the corresponding icon in the top bar.
65
+ - Widget open/closed state is now persisted in localStorage. Widgets that were open will remain open after a page reload, and widgets that were closed will remain closed. Widgets with no saved state fall back to their default (Recent Activity defaults to open; others default to closed).
66
+ - The widget system now supports both left-side and right-side widget panels, which operate independently (one widget per side can be open at a time).
67
+
1
68
  # 0.75.0
2
69
  2026-02-10
3
70
 
@@ -10,7 +10,14 @@
10
10
 
11
11
  <body data-template-id="default">
12
12
  <nav id="nav-global">
13
- <button class="menu-button" aria-label="Menu">☰</button>
13
+ <div class="nav-left-controls">
14
+ <button class="menu-button" aria-label="Menu">☰</button>
15
+ <div class="widget-bar widget-bar-left">
16
+ <button class="widget-button" data-widget="recent-activity" data-widget-side="left" aria-label="Recent Activity" title="Recent Activity">
17
+ <span class="widget-icon">🕒</span>
18
+ </button>
19
+ </div>
20
+ </div>
14
21
 
15
22
  <div class="nav-center">
16
23
  <nav id="nav-main-top">
@@ -37,15 +44,40 @@
37
44
  </div>
38
45
  </nav>
39
46
 
40
- <!-- Widget dropdown panel (shared by all widgets) -->
47
+ <!-- Widget dropdown panel for LEFT-side widgets -->
48
+ <div id="widget-dropdown-left" class="widget-dropdown widget-dropdown-left hidden">
49
+ <div id="widget-content-recent-activity" class="widget-content" data-widget="recent-activity">
50
+ <div class="widget-header">
51
+ <span class="widget-header-title">Recent Activity</span>
52
+ <button class="widget-close-btn" aria-label="Close">✕</button>
53
+ </div>
54
+ <div class="recent-activity-list">
55
+ <!-- Populated by JavaScript -->
56
+ </div>
57
+ </div>
58
+ </div>
59
+
60
+ <!-- Widget dropdown panel for RIGHT-side widgets -->
41
61
  <div id="widget-dropdown" class="widget-dropdown hidden">
42
62
  <div id="widget-content-toc" class="widget-content" data-widget="toc">
63
+ <div class="widget-header">
64
+ <span class="widget-header-title">Table of Contents</span>
65
+ <button class="widget-close-btn" aria-label="Close">✕</button>
66
+ </div>
43
67
  <!-- TOC will be generated by JavaScript -->
44
68
  </div>
45
69
  <div id="widget-content-search" class="widget-content" data-widget="search">
70
+ <div class="widget-header">
71
+ <span class="widget-header-title">Search</span>
72
+ <button class="widget-close-btn" aria-label="Close">✕</button>
73
+ </div>
46
74
  <!-- Search input + results placed here by JS -->
47
75
  </div>
48
76
  <div id="widget-content-profile" class="widget-content" data-widget="profile">
77
+ <div class="widget-header">
78
+ <span class="widget-header-title">Profile</span>
79
+ <button class="widget-close-btn" aria-label="Close">✕</button>
80
+ </div>
49
81
  <div class="widget-profile-placeholder">
50
82
  <span class="widget-profile-avatar">👤</span>
51
83
  <p>Sign in to access your profile</p>
package/meta/default.css CHANGED
@@ -72,12 +72,19 @@ nav#nav-global {
72
72
  background: none;
73
73
  border: none;
74
74
  cursor: pointer;
75
- justify-self: start;
76
75
  }
77
76
  button.menu-button:hover {
78
77
  opacity: 0.7;
79
78
  }
80
79
 
80
+ /* Left controls: menu button + left-side widgets */
81
+ .nav-left-controls {
82
+ display: flex;
83
+ align-items: center;
84
+ justify-self: start;
85
+ gap: 0;
86
+ }
87
+
81
88
  /* Center container for search and top menu */
82
89
  .nav-center {
83
90
  width: calc(var(--article-width) + 38px);
@@ -128,7 +135,7 @@ nav#nav-global {
128
135
 
129
136
  /* ==========================================
130
137
  WIDGET SYSTEM STYLES
131
- Right-side nav widgets (TOC, Search, Profile)
138
+ Nav widgets (TOC, Search, Profile, Recent Activity)
132
139
  ========================================== */
133
140
 
134
141
  /* Widget bar in the nav right column */
@@ -174,7 +181,7 @@ nav#nav-global .nav-right-controls {
174
181
  pointer-events: none;
175
182
  }
176
183
 
177
- /* Widget dropdown panel */
184
+ /* Widget dropdown panel (right-side, default) */
178
185
  .widget-dropdown {
179
186
  position: fixed;
180
187
  top: var(--global-nav-height);
@@ -190,10 +197,54 @@ nav#nav-global .nav-right-controls {
190
197
  transition: opacity 0.15s ease;
191
198
  }
192
199
 
200
+ /* Widget dropdown panel (left-side) */
201
+ .widget-dropdown.widget-dropdown-left {
202
+ right: auto;
203
+ left: 0;
204
+ border-left: none;
205
+ border-right: 1px solid var(--widget-border);
206
+ box-shadow: 2px 4px 12px rgba(0, 0, 0, 0.2);
207
+ }
208
+
193
209
  .widget-dropdown.hidden {
194
210
  display: none;
195
211
  }
196
212
 
213
+ /* Widget header with title and close button */
214
+ .widget-header {
215
+ display: flex;
216
+ align-items: center;
217
+ justify-content: space-between;
218
+ padding: 0.5rem 0.75rem;
219
+ border-bottom: 1px solid var(--widget-border);
220
+ }
221
+
222
+ .widget-header-title {
223
+ font-weight: 600;
224
+ font-size: 0.85rem;
225
+ text-transform: uppercase;
226
+ letter-spacing: 0.03em;
227
+ opacity: 0.7;
228
+ }
229
+
230
+ .widget-close-btn {
231
+ background: none;
232
+ border: none;
233
+ color: var(--text-color);
234
+ font-size: 1rem;
235
+ cursor: pointer;
236
+ opacity: 0.5;
237
+ padding: 4px 8px;
238
+ line-height: 1;
239
+ border-radius: 3px;
240
+ transition: opacity 0.15s ease, background-color 0.15s ease;
241
+ }
242
+
243
+ .widget-close-btn:hover {
244
+ opacity: 1;
245
+ background-color: rgba(128, 128, 128, 0.2);
246
+ }
247
+
197
248
  /* Widget content panels — only the active one is visible */
198
249
  .widget-content {
199
250
  display: none;
@@ -374,6 +425,68 @@ nav#nav-global .nav-right-controls {
374
425
  font-style: italic;
375
426
  }
376
427
 
428
+ /* --- Recent Activity Widget --- */
429
+ .recent-activity-list {
430
+ padding: 0;
431
+ }
432
+
433
+ .recent-activity-loading,
434
+ .recent-activity-empty {
435
+ padding: 1.5rem 1rem;
436
+ text-align: center;
437
+ color: var(--text-color);
438
+ opacity: 0.6;
439
+ font-style: italic;
440
+ font-size: 0.85rem;
441
+ }
442
+
443
+ .recent-activity-items {
444
+ list-style: none;
445
+ margin: 0;
446
+ padding: 0;
447
+ }
448
+
449
+ .recent-activity-item {
450
+ display: flex;
451
+ align-items: baseline;
452
+ justify-content: space-between;
453
+ gap: 0.75rem;
454
+ padding: 0.5rem 0.75rem;
455
+ border-bottom: 1px solid var(--widget-border);
456
+ transition: background-color 0.15s ease;
457
+ }
458
+
459
+ .recent-activity-item:last-child {
460
+ border-bottom: none;
461
+ }
462
+
463
+ .recent-activity-item:hover {
464
+ background-color: rgba(128, 128, 128, 0.1);
465
+ }
466
+
467
+ .recent-activity-link {
468
+ color: var(--text-color);
469
+ text-decoration: none;
470
+ font-size: 0.9rem;
471
+ flex: 1;
472
+ min-width: 0;
473
+ overflow: hidden;
474
+ text-overflow: ellipsis;
475
+ white-space: nowrap;
476
+ }
477
+
478
+ .recent-activity-link:hover {
479
+ text-decoration: underline;
480
+ }
481
+
482
+ .recent-activity-time {
483
+ font-size: 0.75rem;
484
+ color: var(--text-color);
485
+ opacity: 0.5;
486
+ white-space: nowrap;
487
+ flex-shrink: 0;
488
+ }
489
+
377
490
  /* Search functionality styles */
378
491
  .search-results {
379
492
  position: fixed;
package/meta/widgets.js CHANGED
@@ -1,23 +1,65 @@
1
1
  /**
2
- * Widget system for the top nav right-side panel.
2
+ * Widget system for the top nav panel.
3
3
  *
4
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.
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
7
  *
8
- * Built-in widgets: TOC, Search, Profile
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
9
13
  */
10
14
  class WidgetManager {
11
15
  constructor() {
12
- this.dropdown = document.getElementById('widget-dropdown');
16
+ this.dropdownRight = document.getElementById('widget-dropdown');
17
+ this.dropdownLeft = document.getElementById('widget-dropdown-left');
13
18
  this.buttons = document.querySelectorAll('.widget-button[data-widget]');
14
- this.activeWidget = null;
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']);
15
24
 
16
- if (!this.dropdown || this.buttons.length === 0) return;
25
+ if (this.buttons.length === 0) return;
17
26
 
18
27
  this.init();
19
28
  }
20
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
+
21
63
  init() {
22
64
  // Bind button clicks
23
65
  this.buttons.forEach(btn => {
@@ -28,32 +70,92 @@ class WidgetManager {
28
70
  });
29
71
  });
30
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
+
31
85
  // Close on outside click
32
86
  document.addEventListener('click', (e) => {
33
- if (this.activeWidget &&
34
- !this.dropdown.contains(e.target) &&
87
+ // Close right-side widget if click is outside
88
+ if (this.activeRight && this.dropdownRight &&
89
+ !this.dropdownRight.contains(e.target) &&
35
90
  !e.target.closest('.widget-button')) {
36
- this.close();
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');
37
98
  }
38
99
  });
39
100
 
40
101
  // Close on Escape
41
102
  document.addEventListener('keydown', (e) => {
42
- if (e.key === 'Escape' && this.activeWidget) {
43
- this.close();
103
+ if (e.key === 'Escape') {
104
+ if (this.activeRight) this.close('right');
105
+ if (this.activeLeft) this.close('left');
44
106
  }
45
107
  });
46
108
 
47
109
  // Initialize search widget content
48
110
  this.initSearchWidget();
111
+
112
+ // Initialize recent activity widget
113
+ this.initRecentActivityWidget();
114
+
115
+ // Restore saved widget states from localStorage
116
+ this.restoreState();
49
117
  }
50
118
 
51
119
  /**
52
- * Toggle a widget open/closed. If a different widget is open, switch to the new one.
120
+ * Save widget open/closed state to localStorage
121
+ */
122
+ saveState(widgetName, isOpen) {
123
+ try {
124
+ const key = `ursa-widget-${widgetName}`;
125
+ localStorage.setItem(key, isOpen ? 'open' : 'closed');
126
+ } catch (e) { /* localStorage not available */ }
127
+ }
128
+
129
+ /**
130
+ * Restore widget states from localStorage.
131
+ * For widgets with no saved state, use their default (defaultOpen set).
132
+ */
133
+ restoreState() {
134
+ // Gather all widget names
135
+ const widgetNames = new Set();
136
+ this.buttons.forEach(btn => widgetNames.add(btn.dataset.widget));
137
+
138
+ for (const widgetName of widgetNames) {
139
+ const key = `ursa-widget-${widgetName}`;
140
+ let saved;
141
+ try {
142
+ saved = localStorage.getItem(key);
143
+ } catch (e) { /* localStorage not available */ }
144
+
145
+ const shouldOpen = saved === 'open' || (saved === null && this.defaultOpen.has(widgetName));
146
+ if (shouldOpen) {
147
+ this.open(widgetName);
148
+ }
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Toggle a widget open/closed.
53
154
  */
54
155
  toggle(widgetName) {
55
- if (this.activeWidget === widgetName) {
56
- this.close();
156
+ const side = this.getSide(widgetName);
157
+ if (this.getActive(side) === widgetName) {
158
+ this.close(side);
57
159
  return;
58
160
  }
59
161
 
@@ -64,54 +166,80 @@ class WidgetManager {
64
166
  * Open a specific widget panel.
65
167
  */
66
168
  open(widgetName) {
67
- // Close any open widget first
68
- if (this.activeWidget) {
69
- this.deactivateContent(this.activeWidget);
169
+ const side = this.getSide(widgetName);
170
+ const dropdown = this.getDropdown(side);
171
+ if (!dropdown) return;
172
+
173
+ // Close any open widget on the same side first
174
+ const currentActive = this.getActive(side);
175
+ if (currentActive) {
176
+ this.deactivateContent(currentActive);
177
+ // Save the closed widget's state
178
+ this.saveState(currentActive, false);
70
179
  }
71
180
 
72
- this.activeWidget = widgetName;
181
+ this.setActive(side, widgetName);
73
182
 
74
183
  // Show dropdown
75
- this.dropdown.classList.remove('hidden');
76
- this.dropdown.dataset.activeWidget = widgetName;
184
+ dropdown.classList.remove('hidden');
185
+ dropdown.dataset.activeWidget = widgetName;
77
186
 
78
187
  // Show the correct content panel
79
188
  this.activateContent(widgetName);
80
189
 
81
- // Update button states
190
+ // Update button states (only for this side's buttons)
82
191
  this.buttons.forEach(btn => {
83
- btn.classList.toggle('active', btn.dataset.widget === widgetName);
192
+ if (this.getSide(btn.dataset.widget) === side) {
193
+ btn.classList.toggle('active', btn.dataset.widget === widgetName);
194
+ }
84
195
  });
85
196
 
197
+ // Save state
198
+ this.saveState(widgetName, true);
199
+
86
200
  // Fire event for other scripts to listen to
87
- document.dispatchEvent(new CustomEvent('widget-opened', { detail: { widget: widgetName } }));
201
+ document.dispatchEvent(new CustomEvent('widget-opened', { detail: { widget: widgetName, side } }));
88
202
  }
89
203
 
90
204
  /**
91
- * Close the currently open widget.
205
+ * Close the currently open widget on a given side.
92
206
  */
93
- close() {
94
- if (!this.activeWidget) return;
207
+ close(side) {
208
+ const active = this.getActive(side);
209
+ if (!active) return;
95
210
 
96
- const closing = this.activeWidget;
97
- this.deactivateContent(closing);
211
+ const dropdown = this.getDropdown(side);
212
+ this.deactivateContent(active);
98
213
 
99
- this.activeWidget = null;
100
- this.dropdown.classList.add('hidden');
101
- delete this.dropdown.dataset.activeWidget;
214
+ // Save state
215
+ this.saveState(active, false);
216
+
217
+ this.setActive(side, null);
218
+ if (dropdown) {
219
+ dropdown.classList.add('hidden');
220
+ delete dropdown.dataset.activeWidget;
221
+ }
102
222
 
103
- // Update button states
104
- this.buttons.forEach(btn => btn.classList.remove('active'));
223
+ // Update button states for this side
224
+ this.buttons.forEach(btn => {
225
+ if (this.getSide(btn.dataset.widget) === side) {
226
+ btn.classList.remove('active');
227
+ }
228
+ });
105
229
 
106
230
  // Fire event
107
- document.dispatchEvent(new CustomEvent('widget-closed', { detail: { widget: closing } }));
231
+ document.dispatchEvent(new CustomEvent('widget-closed', { detail: { widget: active, side } }));
108
232
  }
109
233
 
110
234
  /**
111
235
  * Show a widget's content panel.
112
236
  */
113
237
  activateContent(widgetName) {
114
- const content = this.dropdown.querySelector(`.widget-content[data-widget="${widgetName}"]`);
238
+ const side = this.getSide(widgetName);
239
+ const dropdown = this.getDropdown(side);
240
+ if (!dropdown) return;
241
+
242
+ const content = dropdown.querySelector(`.widget-content[data-widget="${widgetName}"]`);
115
243
  if (content) {
116
244
  content.classList.add('active');
117
245
  }
@@ -126,7 +254,11 @@ class WidgetManager {
126
254
  * Hide a widget's content panel.
127
255
  */
128
256
  deactivateContent(widgetName) {
129
- const content = this.dropdown.querySelector(`.widget-content[data-widget="${widgetName}"]`);
257
+ const side = this.getSide(widgetName);
258
+ const dropdown = this.getDropdown(side);
259
+ if (!dropdown) return;
260
+
261
+ const content = dropdown.querySelector(`.widget-content[data-widget="${widgetName}"]`);
130
262
  if (content) {
131
263
  content.classList.remove('active');
132
264
  }
@@ -368,6 +500,73 @@ class WidgetManager {
368
500
  deactivateSearch() {
369
501
  // Keep the search query so user can re-open and see results
370
502
  }
503
+
504
+ /**
505
+ * Initialize the Recent Activity widget — fetch data and render the list.
506
+ */
507
+ initRecentActivityWidget() {
508
+ const container = document.querySelector('.recent-activity-list');
509
+ if (!container) return;
510
+
511
+ container.innerHTML = '<div class="recent-activity-loading">Loading...</div>';
512
+
513
+ fetch('/public/recent-activity.json')
514
+ .then(res => {
515
+ if (!res.ok) throw new Error('Not found');
516
+ return res.json();
517
+ })
518
+ .then(items => {
519
+ container.innerHTML = '';
520
+ if (!items || items.length === 0) {
521
+ container.innerHTML = '<div class="recent-activity-empty">No recent activity</div>';
522
+ return;
523
+ }
524
+ const ul = document.createElement('ul');
525
+ ul.className = 'recent-activity-items';
526
+ for (const item of items) {
527
+ const li = document.createElement('li');
528
+ li.className = 'recent-activity-item';
529
+ const a = document.createElement('a');
530
+ a.href = item.url;
531
+ a.textContent = item.title || 'Untitled';
532
+ a.className = 'recent-activity-link';
533
+ const time = document.createElement('span');
534
+ time.className = 'recent-activity-time';
535
+ time.textContent = this.formatRelativeTime(item.mtime);
536
+ time.title = new Date(item.mtime).toLocaleString();
537
+ li.appendChild(a);
538
+ li.appendChild(time);
539
+ ul.appendChild(li);
540
+ }
541
+ container.appendChild(ul);
542
+ })
543
+ .catch(() => {
544
+ container.innerHTML = '<div class="recent-activity-empty">Recent activity unavailable</div>';
545
+ });
546
+ }
547
+
548
+ /**
549
+ * Format a timestamp into a human-readable relative time string.
550
+ */
551
+ formatRelativeTime(mtimeMs) {
552
+ const now = Date.now();
553
+ const diff = now - mtimeMs;
554
+ const seconds = Math.floor(diff / 1000);
555
+ const minutes = Math.floor(seconds / 60);
556
+ const hours = Math.floor(minutes / 60);
557
+ const days = Math.floor(hours / 24);
558
+ const weeks = Math.floor(days / 7);
559
+ const months = Math.floor(days / 30);
560
+ const years = Math.floor(days / 365);
561
+
562
+ if (seconds < 60) return 'just now';
563
+ if (minutes < 60) return `${minutes}m ago`;
564
+ if (hours < 24) return `${hours}h ago`;
565
+ if (days < 7) return `${days}d ago`;
566
+ if (weeks < 5) return `${weeks}w ago`;
567
+ if (months < 12) return `${months}mo ago`;
568
+ return `${years}y ago`;
569
+ }
371
570
  }
372
571
 
373
572
  // Initialize widgets when DOM is ready
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.76.0",
6
6
  "description": "static site generator from MD/wikitext/YML",
7
7
  "main": "lib/index.js",
8
8
  "bin": {
package/src/dev.js CHANGED
@@ -20,7 +20,7 @@ const { readFile, readdir, stat, mkdir } = promises;
20
20
  // Import helper modules
21
21
  import { renderFileAsync } from "./helper/fileRenderer.js";
22
22
  import { findStyleCss } from "./helper/findStyleCss.js";
23
- import { findScriptJs } from "./helper/findScriptJs.js";
23
+ import { findAllScriptJs } from "./helper/findScriptJs.js";
24
24
  import { extractMetadata, getAutoIndexConfig, isMetadataOnly } from "./helper/metadataExtractor.js";
25
25
  import { injectFrontmatterTable } from "./helper/frontmatterTable.js";
26
26
  import { buildValidPaths, markInactiveLinks, resolveRelativeUrls } from "./helper/linkValidator.js";
@@ -58,6 +58,7 @@ const devState = {
58
58
  footer: null,
59
59
  fullTextIndex: null,
60
60
  searchIndex: null,
61
+ recentActivity: null,
61
62
 
62
63
  // Path → nearest menu.md mapping
63
64
  menuPathMap: new Map(),
@@ -282,19 +283,19 @@ async function findNearestStyle(dirPath) {
282
283
  }
283
284
 
284
285
  /**
285
- * Find the nearest script.js
286
+ * Find all script.js files from docroot to dirPath
286
287
  */
287
- async function findNearestScript(dirPath) {
288
- const { scriptPathMap } = devState;
288
+ async function findAllScripts(dirPath) {
289
+ const { scriptPathMap, source } = devState;
289
290
 
290
291
  // Check cache first
291
292
  if (scriptPathMap.has(dirPath)) {
292
293
  return scriptPathMap.get(dirPath);
293
294
  }
294
295
 
295
- const scriptPath = await findScriptJs(dirPath);
296
- scriptPathMap.set(dirPath, scriptPath);
297
- return scriptPath;
296
+ const scriptPaths = await findAllScriptJs(dirPath, source);
297
+ scriptPathMap.set(dirPath, scriptPaths);
298
+ return scriptPaths;
298
299
  }
299
300
 
300
301
  /**
@@ -457,15 +458,17 @@ async function wrapInTemplate(body, title, fileMeta, urlPath, sourcePath) {
457
458
  // Ignore CSS errors
458
459
  }
459
460
 
460
- // Find nearest script.js and inline its contents
461
+ // Find all script.js files from docroot to current dir and inline their contents
461
462
  let customScript = "";
462
463
  try {
463
464
  const scriptDir = sourcePath ? dirname(sourcePath) : source;
464
- const scriptPath = await findNearestScript(scriptDir);
465
- if (scriptPath) {
465
+ const scriptPaths = await findAllScripts(scriptDir);
466
+ const scriptTags = [];
467
+ for (const scriptPath of scriptPaths) {
466
468
  const scriptContent = await readFile(scriptPath, 'utf8');
467
- customScript = `<script>\n${scriptContent}\n</script>`;
469
+ scriptTags.push(`<script>\n${scriptContent}\n</script>`);
468
470
  }
471
+ customScript = scriptTags.join('\n');
469
472
  } catch (e) {
470
473
  // Ignore script errors
471
474
  }
@@ -635,6 +638,7 @@ async function buildBackgroundCaches() {
635
638
 
636
639
  const searchIndex = [];
637
640
  const fullTextDocs = [];
641
+ const recentActivity = [];
638
642
 
639
643
  for (const article of allArticles) {
640
644
  try {
@@ -657,6 +661,16 @@ async function buildBackgroundCaches() {
657
661
  title,
658
662
  content
659
663
  });
664
+
665
+ // Collect mtime for recent activity
666
+ try {
667
+ const articleStat = await stat(article);
668
+ recentActivity.push({
669
+ title,
670
+ url: relativePath.startsWith('/') ? relativePath : '/' + relativePath,
671
+ mtime: articleStat.mtimeMs
672
+ });
673
+ } catch (e) {}
660
674
  } catch (e) {}
661
675
  }
662
676
 
@@ -664,6 +678,10 @@ async function buildBackgroundCaches() {
664
678
  devState.fullTextIndex = buildFullTextIndex(fullTextDocs);
665
679
  devState.searchIndex = searchIndex;
666
680
 
681
+ // Build recent activity (top 10 by mtime)
682
+ recentActivity.sort((a, b) => b.mtime - a.mtime);
683
+ devState.recentActivity = recentActivity.slice(0, 10);
684
+
667
685
  // Write search index files
668
686
  const publicDir = join(output, 'public');
669
687
  await mkdir(publicDir, { recursive: true });
@@ -671,6 +689,7 @@ async function buildBackgroundCaches() {
671
689
  await outputFile(join(publicDir, 'search-index.json'), JSON.stringify(searchIndex));
672
690
  await outputFile(join(publicDir, 'fulltext-index.json'), JSON.stringify(devState.fullTextIndex));
673
691
  await outputFile(join(publicDir, 'menu-data.json'), JSON.stringify(devState.menuData));
692
+ await outputFile(join(publicDir, 'recent-activity.json'), JSON.stringify(devState.recentActivity));
674
693
 
675
694
  devState.searchReady = true;
676
695
  console.log('✅ Search index ready');
@@ -956,6 +975,33 @@ export async function dev({
956
975
  console.log(`✅ MDX caches cleared for component change: ${name}`);
957
976
  }
958
977
 
978
+ // Update recent activity for article changes
979
+ const isArticleChange = name && /\.(md|mdx|txt|yml)$/.test(name);
980
+ if (isArticleChange && devState.recentActivity) {
981
+ try {
982
+ const articleStat = await stat(name);
983
+ const ext = extname(name);
984
+ const base = basename(name, ext);
985
+ const relativePath = name.replace(sourceDir + '/', '').replace(/\.(md|mdx|txt|yml)$/, '.html');
986
+ const titleBase = (base === 'index' || base === 'home') ? basename(dirname(name)) : base;
987
+ const title = toTitleCase(titleBase || base);
988
+ const url = relativePath.startsWith('/') ? relativePath : '/' + relativePath;
989
+
990
+ // Remove old entry for this URL, add updated one
991
+ let activity = devState.recentActivity.filter(r => r.url !== url);
992
+ activity.push({ title, url, mtime: articleStat.mtimeMs });
993
+ activity.sort((a, b) => b.mtime - a.mtime);
994
+ devState.recentActivity = activity.slice(0, 10);
995
+
996
+ // Write updated file
997
+ const publicDir = join(devState.output, 'public');
998
+ await outputFile(join(publicDir, 'recent-activity.json'), JSON.stringify(devState.recentActivity));
999
+ console.log(`✅ Recent activity updated for ${name}`);
1000
+ } catch (e) {
1001
+ // ignore
1002
+ }
1003
+ }
1004
+
959
1005
  // Broadcast reload
960
1006
  broadcast('reload', { file: name });
961
1007
  });
@@ -4,7 +4,7 @@ import { readdir, readFile } from "fs/promises";
4
4
  import { basename, dirname, extname, join } from "path";
5
5
  import { outputFile } from "fs-extra";
6
6
  import { findStyleCss } from "../findStyleCss.js";
7
- import { findScriptJs } from "../findScriptJs.js";
7
+ import { findAllScriptJs } from "../findScriptJs.js";
8
8
  import { toTitleCase } from "./titleCase.js";
9
9
  import { addTimestampToHtmlStaticRefs } from "./cacheBust.js";
10
10
  import { isMetadataOnly, extractMetadata, getAutoIndexConfig } from "../metadataExtractor.js";
@@ -370,15 +370,17 @@ export async function generateAutoIndices(output, directories, source, templates
370
370
  // ignore CSS lookup errors
371
371
  }
372
372
 
373
- // Find nearest script.js for this directory
373
+ // Find all script.js files from docroot to this directory
374
374
  let customScript = "";
375
375
  try {
376
376
  const sourceDir = dir.replace(outputNorm, sourceNorm);
377
- const scriptPath = await findScriptJs(sourceDir);
378
- if (scriptPath) {
377
+ const scriptPaths = await findAllScriptJs(sourceDir, sourceNorm);
378
+ const scriptTags = [];
379
+ for (const scriptPath of scriptPaths) {
379
380
  const scriptContent = await readFile(scriptPath, 'utf8');
380
- customScript = `<script>\n${scriptContent}\n</script>`;
381
+ scriptTags.push(`<script>\n${scriptContent}\n</script>`);
381
382
  }
383
+ customScript = scriptTags.join('\n');
382
384
  } catch (e) {
383
385
  // ignore script lookup errors
384
386
  }
@@ -24,3 +24,32 @@ export async function findScriptJs(startDir, names = ["script.js", "_script.js"]
24
24
  }
25
25
  return null;
26
26
  }
27
+
28
+ /**
29
+ * Find ALL script.js or _script.js files from the docroot down to startDir.
30
+ * Walks up from startDir to docroot collecting all matches, then returns them
31
+ * sorted from shortest path (closest to docroot) to longest (closest to startDir).
32
+ * @param {string} startDir - Directory to start searching from (deepest)
33
+ * @param {string} docroot - The root directory to stop at (shallowest)
34
+ * @param {string[]} [names=["script.js", "_script.js"]] - Filenames to look for
35
+ * @returns {Promise<string[]>} Array of script file paths, ordered from shallowest to deepest
36
+ */
37
+ export async function findAllScriptJs(startDir, docroot, names = ["script.js", "_script.js"]) {
38
+ const found = [];
39
+ let dir = resolve(startDir);
40
+ const base = resolve(docroot);
41
+ while (true) {
42
+ for (const name of names) {
43
+ const candidate = join(dir, name);
44
+ if (existsSync(candidate)) {
45
+ found.push(candidate);
46
+ break; // Only one match per directory (prefer script.js over _script.js)
47
+ }
48
+ }
49
+ if (dir === base || dir === dirname(dir)) break;
50
+ dir = dirname(dir);
51
+ }
52
+ // Sort from shortest path (docroot) to longest (startDir)
53
+ found.sort((a, b) => a.length - b.length);
54
+ return found;
55
+ }
@@ -27,7 +27,7 @@ import { getAndIncrementBuildId } from "../helper/ursaConfig.js";
27
27
  import { extractSections } from "../helper/sectionExtractor.js";
28
28
  import { renderFile, renderFileAsync, terminateParserPool } from "../helper/fileRenderer.js";
29
29
  import { findStyleCss } from "../helper/findStyleCss.js";
30
- import { findScriptJs } from "../helper/findScriptJs.js";
30
+ import { findScriptJs, findAllScriptJs } from "../helper/findScriptJs.js";
31
31
  import { buildFullTextIndex, buildIncrementalIndex, loadIndexCache, saveIndexCache } from "../helper/fullTextIndex.js";
32
32
  import { copy as copyDir, emptyDir, outputFile } from "fs-extra";
33
33
  import { basename, dirname, extname, join, parse, resolve } from "path";
@@ -313,6 +313,8 @@ export async function generate({
313
313
  const searchIndex = [];
314
314
  // Full-text index: collect documents for word-to-document mapping
315
315
  const fullTextDocs = [];
316
+ // Recent activity: collect {title, url, mtime} for all articles, keep top 10 by mtime
317
+ const recentActivity = [];
316
318
  // Track paths of documents that were regenerated (for incremental index updates)
317
319
  const changedPaths = new Set();
318
320
  // Directory index cache: only stores minimal data needed for directory indices
@@ -481,6 +483,18 @@ export async function generate({
481
483
  title: title,
482
484
  content: rawBody
483
485
  });
486
+
487
+ // Collect mtime for recent activity tracking
488
+ try {
489
+ const fileStat = await stat(file);
490
+ recentActivity.push({
491
+ title: title,
492
+ url: searchUrl,
493
+ mtime: fileStat.mtimeMs
494
+ });
495
+ } catch (e) {
496
+ // ignore stat errors
497
+ }
484
498
 
485
499
  // Check if a corresponding .html file already exists in source directory
486
500
  const outputHtmlRelative = relativePath.startsWith('/') ? relativePath.slice(1) : relativePath;
@@ -614,21 +628,22 @@ export async function generate({
614
628
  console.error(e);
615
629
  }
616
630
 
617
- // Find nearest script.js or _script.js up the tree and inline its contents
631
+ // Find all script.js or _script.js files from docroot to current dir and inline their contents
618
632
  // Use cache to avoid repeated filesystem walks for same directory
619
633
  let customScript = "";
620
634
  try {
621
635
  const dirKey = (dir === "/" || dir === "") ? _source : resolve(_source, dir);
622
- let scriptPath = scriptPathCache.get(dirKey);
623
- if (scriptPath === undefined) {
624
- scriptPath = await findScriptJs(dirKey);
625
- scriptPathCache.set(dirKey, scriptPath); // Cache null results too
636
+ let scriptPaths = scriptPathCache.get(dirKey);
637
+ if (scriptPaths === undefined) {
638
+ scriptPaths = await findAllScriptJs(dirKey, _source);
639
+ scriptPathCache.set(dirKey, scriptPaths); // Cache empty arrays too
626
640
  }
627
- if (scriptPath) {
641
+ const scriptTags = [];
642
+ for (const scriptPath of scriptPaths) {
628
643
  const scriptContent = await readFile(scriptPath, 'utf8');
629
- // Inline the script content in a script tag
630
- customScript = `<script>\n${scriptContent}\n</script>`;
644
+ scriptTags.push(`<script>\n${scriptContent}\n</script>`);
631
645
  }
646
+ customScript = scriptTags.join('\n');
632
647
  } catch (e) {
633
648
  // ignore
634
649
  console.error(e);
@@ -807,6 +822,17 @@ export async function generate({
807
822
  profiler.endPhase('Write search index');
808
823
  }
809
824
 
825
+ // Phase: Write recent activity data
826
+ profiler.startPhase('Write recent activity');
827
+ progress.startTimer('Recent activity');
828
+ // Sort by mtime descending, keep top 10
829
+ recentActivity.sort((a, b) => b.mtime - a.mtime);
830
+ const top10 = recentActivity.slice(0, 10);
831
+ const recentActivityPath = join(output, 'public', 'recent-activity.json');
832
+ await outputFile(recentActivityPath, JSON.stringify(top10));
833
+ progress.done('Recent activity', `${top10.length} entries [${progress.stopTimer('Recent activity')}]`);
834
+ profiler.endPhase('Write recent activity');
835
+
810
836
  // Phase: Write menu data
811
837
  profiler.startPhase('Write menu data');
812
838
  progress.startTimer('Menu data');
@@ -1209,15 +1235,17 @@ export async function regenerateSingleFile(changedFile, {
1209
1235
  return { success: false, message: `Template not found: ${requestedTemplateName || DEFAULT_TEMPLATE_NAME}` };
1210
1236
  }
1211
1237
 
1212
- // Find nearest script.js or _script.js and inline its contents
1238
+ // Find all script.js or _script.js files from docroot to current dir and inline their contents
1213
1239
  let customScript = "";
1214
1240
  try {
1215
1241
  const dirKey = (dir === "/" || dir === "") ? _source : resolve(_source, dir);
1216
- const scriptPath = await findScriptJs(dirKey);
1217
- if (scriptPath) {
1242
+ const scriptPaths = await findAllScriptJs(dirKey, _source);
1243
+ const scriptTags = [];
1244
+ for (const scriptPath of scriptPaths) {
1218
1245
  const scriptContent = await readFile(scriptPath, 'utf8');
1219
- customScript = `<script>\n${scriptContent}\n</script>`;
1246
+ scriptTags.push(`<script>\n${scriptContent}\n</script>`);
1220
1247
  }
1248
+ customScript = scriptTags.join('\n');
1221
1249
  } catch (e) {
1222
1250
  // ignore
1223
1251
  }
@@ -1296,6 +1324,27 @@ export async function regenerateSingleFile(changedFile, {
1296
1324
  // Update hash cache
1297
1325
  updateHash(changedFile, rawBody, hashCache);
1298
1326
 
1327
+ // Update recent-activity.json with this file's new mtime
1328
+ try {
1329
+ const fileStat = await stat(changedFile);
1330
+ const recentActivityPath = join(output, 'public', 'recent-activity.json');
1331
+ let recentActivity = [];
1332
+ try {
1333
+ const existing = await readFile(recentActivityPath, 'utf8');
1334
+ recentActivity = JSON.parse(existing);
1335
+ } catch (e) { /* no existing file, start fresh */ }
1336
+ // Remove old entry for this URL if present
1337
+ recentActivity = recentActivity.filter(r => r.url !== url);
1338
+ // Add updated entry
1339
+ recentActivity.push({ title, url, mtime: fileStat.mtimeMs });
1340
+ // Sort by mtime descending, keep top 10
1341
+ recentActivity.sort((a, b) => b.mtime - a.mtime);
1342
+ recentActivity = recentActivity.slice(0, 10);
1343
+ await outputFile(recentActivityPath, JSON.stringify(recentActivity));
1344
+ } catch (e) {
1345
+ // ignore recent activity update errors
1346
+ }
1347
+
1299
1348
  const elapsed = Date.now() - startTime;
1300
1349
  const shortFile = changedFile.replace(source, '');
1301
1350
  return { success: true, message: `Regenerated ${shortFile} in ${elapsed}ms` };