@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 +67 -0
- package/meta/default-template.html +34 -2
- package/meta/default.css +116 -3
- package/meta/widgets.js +236 -37
- package/package.json +1 -1
- package/src/dev.js +57 -11
- package/src/helper/build/autoIndex.js +7 -5
- package/src/helper/findScriptJs.js +29 -0
- package/src/jobs/generate.js +62 -13
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
|
-
<
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
*
|
|
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.
|
|
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.
|
|
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 (
|
|
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
|
|
34
|
-
|
|
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'
|
|
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
|
-
*
|
|
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
|
-
|
|
56
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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.
|
|
181
|
+
this.setActive(side, widgetName);
|
|
73
182
|
|
|
74
183
|
// Show dropdown
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
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
|
-
|
|
207
|
+
close(side) {
|
|
208
|
+
const active = this.getActive(side);
|
|
209
|
+
if (!active) return;
|
|
95
210
|
|
|
96
|
-
const
|
|
97
|
-
this.deactivateContent(
|
|
211
|
+
const dropdown = this.getDropdown(side);
|
|
212
|
+
this.deactivateContent(active);
|
|
98
213
|
|
|
99
|
-
|
|
100
|
-
this.
|
|
101
|
-
|
|
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 =>
|
|
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:
|
|
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
|
|
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
|
|
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
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 {
|
|
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
|
|
286
|
+
* Find all script.js files from docroot to dirPath
|
|
286
287
|
*/
|
|
287
|
-
async function
|
|
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
|
|
296
|
-
scriptPathMap.set(dirPath,
|
|
297
|
-
return
|
|
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
|
|
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
|
|
465
|
-
|
|
465
|
+
const scriptPaths = await findAllScripts(scriptDir);
|
|
466
|
+
const scriptTags = [];
|
|
467
|
+
for (const scriptPath of scriptPaths) {
|
|
466
468
|
const scriptContent = await readFile(scriptPath, 'utf8');
|
|
467
|
-
|
|
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 {
|
|
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
|
|
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
|
|
378
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/jobs/generate.js
CHANGED
|
@@ -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
|
|
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
|
|
623
|
-
if (
|
|
624
|
-
|
|
625
|
-
scriptPathCache.set(dirKey,
|
|
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
|
-
|
|
641
|
+
const scriptTags = [];
|
|
642
|
+
for (const scriptPath of scriptPaths) {
|
|
628
643
|
const scriptContent = await readFile(scriptPath, 'utf8');
|
|
629
|
-
|
|
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
|
|
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
|
|
1217
|
-
|
|
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
|
-
|
|
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` };
|