@kenjura/ursa 0.82.0 → 0.83.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 +7 -0
- package/bin/ursa.js +17 -0
- package/meta/templates/default-template/search.js +38 -0
- package/meta/templates/default-template/widgets.js +13 -0
- package/package.json +1 -1
- package/src/helper/__test__/promoteChangelog.test.js +94 -0
- package/src/helper/promoteChangelog.js +107 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
|
|
2
|
+
# 0.83.0
|
|
3
|
+
2026-05-07
|
|
4
|
+
|
|
5
|
+
- **New `--promote-changelog=<file.md>` option** for the `generate` and `serve` commands. When supplied, the named markdown (or .mdx) file is staged into the source root for the duration of the build, so it is rendered by the normal Ursa pipeline and ends up in the output root as a sibling of the main `index.html`. The staged copy is removed after the build completes (or when the serve process exits via SIGINT/SIGTERM). If a file with the same basename already exists in the source root, no staging is performed and a warning is logged.
|
|
6
|
+
- **Search UI fix**: clicking a search suggestion (or selecting one with Enter) now hides the search results dropdown immediately. Previously the dropdown could remain visible when navigation did not trigger a fresh page load (e.g. same-page anchor links, or restoration from the browser's back/forward cache). Also added a `pageshow` listener that hides results when the page is restored from bfcache.
|
|
7
|
+
- **Search UI fully collapses after navigation**: clicking a search suggestion (in either the inline search dropdown or the search widget panel) now also closes the search widget panel and clears the search input, mirroring the effect of clicking the search icon a second time. The input is also cleared when the page is restored from the browser's back/forward cache.
|
|
1
8
|
# 0.82.0
|
|
2
9
|
2026-05-06
|
|
3
10
|
|
package/bin/ursa.js
CHANGED
|
@@ -5,6 +5,7 @@ import { hideBin } from 'yargs/helpers';
|
|
|
5
5
|
import { generate } from '../src/jobs/generate.js';
|
|
6
6
|
import { resolve, dirname, join } from 'path';
|
|
7
7
|
import { fileURLToPath } from 'url';
|
|
8
|
+
import { stagePromotedChangelog, registerCleanupOnExit } from '../src/helper/promoteChangelog.js';
|
|
8
9
|
|
|
9
10
|
// Get the directory where ursa is installed
|
|
10
11
|
const __filename = fileURLToPath(import.meta.url);
|
|
@@ -48,6 +49,10 @@ yargs(hideBin(process.argv))
|
|
|
48
49
|
describe: 'Ignore cached hashes and regenerate all files',
|
|
49
50
|
type: 'boolean',
|
|
50
51
|
default: false
|
|
52
|
+
})
|
|
53
|
+
.option('promote-changelog', {
|
|
54
|
+
describe: 'Path to a markdown file to render at the output root (sibling of index.html)',
|
|
55
|
+
type: 'string'
|
|
51
56
|
});
|
|
52
57
|
},
|
|
53
58
|
async (argv) => {
|
|
@@ -57,6 +62,7 @@ yargs(hideBin(process.argv))
|
|
|
57
62
|
const whitelist = argv.whitelist ? resolve(argv.whitelist) : null;
|
|
58
63
|
const exclude = argv.exclude || null;
|
|
59
64
|
const clean = argv.clean;
|
|
65
|
+
const promoteChangelog = argv['promote-changelog'] || null;
|
|
60
66
|
|
|
61
67
|
console.log(`Generating site from ${source} to ${output} using meta from ${meta}`);
|
|
62
68
|
if (whitelist) {
|
|
@@ -69,7 +75,9 @@ yargs(hideBin(process.argv))
|
|
|
69
75
|
console.log(`Clean build: ignoring cached hashes`);
|
|
70
76
|
}
|
|
71
77
|
|
|
78
|
+
let promoted = { stagedFile: null, cleanup: async () => {} };
|
|
72
79
|
try {
|
|
80
|
+
promoted = await stagePromotedChangelog({ changelogPath: promoteChangelog, sourceDir: source });
|
|
73
81
|
await generate({
|
|
74
82
|
_source: source,
|
|
75
83
|
_meta: meta,
|
|
@@ -82,6 +90,8 @@ yargs(hideBin(process.argv))
|
|
|
82
90
|
} catch (error) {
|
|
83
91
|
console.error('Error generating site:', error.message);
|
|
84
92
|
process.exit(1);
|
|
93
|
+
} finally {
|
|
94
|
+
await promoted.cleanup();
|
|
85
95
|
}
|
|
86
96
|
}
|
|
87
97
|
)
|
|
@@ -127,6 +137,10 @@ yargs(hideBin(process.argv))
|
|
|
127
137
|
describe: 'Ignore cached hashes and regenerate all files',
|
|
128
138
|
type: 'boolean',
|
|
129
139
|
default: false
|
|
140
|
+
})
|
|
141
|
+
.option('promote-changelog', {
|
|
142
|
+
describe: 'Path to a markdown file to render at the output root (sibling of index.html)',
|
|
143
|
+
type: 'string'
|
|
130
144
|
});
|
|
131
145
|
},
|
|
132
146
|
async (argv) => {
|
|
@@ -137,6 +151,7 @@ yargs(hideBin(process.argv))
|
|
|
137
151
|
const whitelist = argv.whitelist ? resolve(argv.whitelist) : null;
|
|
138
152
|
const exclude = argv.exclude || null;
|
|
139
153
|
const clean = argv.clean;
|
|
154
|
+
const promoteChangelog = argv['promote-changelog'] || null;
|
|
140
155
|
|
|
141
156
|
console.log(`Starting development server...`);
|
|
142
157
|
console.log(`Source: ${source}`);
|
|
@@ -151,6 +166,8 @@ yargs(hideBin(process.argv))
|
|
|
151
166
|
}
|
|
152
167
|
|
|
153
168
|
try {
|
|
169
|
+
const promoted = await stagePromotedChangelog({ changelogPath: promoteChangelog, sourceDir: source });
|
|
170
|
+
registerCleanupOnExit(promoted.cleanup);
|
|
154
171
|
const { serve } = await import('../src/serve.js');
|
|
155
172
|
await serve({
|
|
156
173
|
_source: source,
|
|
@@ -590,6 +590,27 @@ class GlobalSearch {
|
|
|
590
590
|
}
|
|
591
591
|
|
|
592
592
|
navigateToResult(result) {
|
|
593
|
+
// Hide the results dropdown immediately. This handles cases where the
|
|
594
|
+
// navigation does not trigger a fresh page load (e.g. same-page anchor
|
|
595
|
+
// links, or restoration from the browser's back/forward cache).
|
|
596
|
+
this.hideResults();
|
|
597
|
+
// Also close the search widget panel (if any) so the search UI fully
|
|
598
|
+
// collapses, mirroring the effect of clicking the search icon again.
|
|
599
|
+
if (window.widgetManager && typeof window.widgetManager.close === 'function') {
|
|
600
|
+
try {
|
|
601
|
+
const side = window.widgetManager.getSide
|
|
602
|
+
? window.widgetManager.getSide('search')
|
|
603
|
+
: undefined;
|
|
604
|
+
window.widgetManager.close(side);
|
|
605
|
+
} catch {
|
|
606
|
+
// ignore — best effort
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
// Clear the search input so the field is empty when the user reopens it.
|
|
610
|
+
if (this.searchInput) {
|
|
611
|
+
this.searchInput.value = '';
|
|
612
|
+
this.updateClearButtonVisibility();
|
|
613
|
+
}
|
|
593
614
|
if (result.url) {
|
|
594
615
|
window.location.href = result.url;
|
|
595
616
|
} else if (result.path) {
|
|
@@ -601,4 +622,21 @@ class GlobalSearch {
|
|
|
601
622
|
// Initialize when DOM is loaded
|
|
602
623
|
document.addEventListener('DOMContentLoaded', () => {
|
|
603
624
|
window.globalSearch = new GlobalSearch();
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
// Also hide search results if the page is restored from the browser's
|
|
628
|
+
// back/forward cache (bfcache). Without this, the search dropdown can
|
|
629
|
+
// appear "stuck" open after returning to a previous page.
|
|
630
|
+
window.addEventListener('pageshow', () => {
|
|
631
|
+
if (window.globalSearch && typeof window.globalSearch.hideResults === 'function') {
|
|
632
|
+
window.globalSearch.hideResults();
|
|
633
|
+
}
|
|
634
|
+
// Also clear the search input on bfcache restore so the field doesn't
|
|
635
|
+
// appear pre-populated with the previous query.
|
|
636
|
+
if (window.globalSearch && window.globalSearch.searchInput) {
|
|
637
|
+
window.globalSearch.searchInput.value = '';
|
|
638
|
+
if (typeof window.globalSearch.updateClearButtonVisibility === 'function') {
|
|
639
|
+
window.globalSearch.updateClearButtonVisibility();
|
|
640
|
+
}
|
|
641
|
+
}
|
|
604
642
|
});
|
|
@@ -464,6 +464,19 @@ class WidgetManager {
|
|
|
464
464
|
item.appendChild(path);
|
|
465
465
|
|
|
466
466
|
item.addEventListener('click', () => {
|
|
467
|
+
// Close the search widget panel before navigating, so it isn't left
|
|
468
|
+
// open when the new page loads (or when a same-page anchor link
|
|
469
|
+
// doesn't trigger a fresh page load at all).
|
|
470
|
+
const side = this.getSide('search');
|
|
471
|
+
// Clear the widget search input so the field is empty next time the
|
|
472
|
+
// user opens the search panel.
|
|
473
|
+
if (this._widgetSearchInput) {
|
|
474
|
+
this._widgetSearchInput.value = '';
|
|
475
|
+
}
|
|
476
|
+
if (this._widgetSearchResults) {
|
|
477
|
+
this._widgetSearchResults.innerHTML = '';
|
|
478
|
+
}
|
|
479
|
+
this.close(side);
|
|
467
480
|
window.location.href = result.url || result.path;
|
|
468
481
|
});
|
|
469
482
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import { jest } from '@jest/globals';
|
|
5
|
+
import { stagePromotedChangelog } from '../promoteChangelog.js';
|
|
6
|
+
|
|
7
|
+
let tmpDir;
|
|
8
|
+
|
|
9
|
+
beforeEach(async () => {
|
|
10
|
+
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'ursa-promote-changelog-'));
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
afterEach(async () => {
|
|
14
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe('stagePromotedChangelog', () => {
|
|
18
|
+
it('is a no-op when no changelogPath is provided', async () => {
|
|
19
|
+
const sourceDir = path.join(tmpDir, 'src');
|
|
20
|
+
await fs.mkdir(sourceDir);
|
|
21
|
+
const result = await stagePromotedChangelog({ changelogPath: null, sourceDir });
|
|
22
|
+
expect(result.stagedFile).toBe(null);
|
|
23
|
+
const entries = await fs.readdir(sourceDir);
|
|
24
|
+
expect(entries).toEqual([]);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('throws when the source file does not exist', async () => {
|
|
28
|
+
const sourceDir = path.join(tmpDir, 'src');
|
|
29
|
+
await fs.mkdir(sourceDir);
|
|
30
|
+
await expect(
|
|
31
|
+
stagePromotedChangelog({
|
|
32
|
+
changelogPath: path.join(tmpDir, 'missing.md'),
|
|
33
|
+
sourceDir,
|
|
34
|
+
})
|
|
35
|
+
).rejects.toThrow(/cannot read file/);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('copies the file into the source root using its basename', async () => {
|
|
39
|
+
const sourceDir = path.join(tmpDir, 'src');
|
|
40
|
+
await fs.mkdir(sourceDir);
|
|
41
|
+
const cl = path.join(tmpDir, 'CHANGELOG.md');
|
|
42
|
+
await fs.writeFile(cl, '# Hello\n');
|
|
43
|
+
|
|
44
|
+
const result = await stagePromotedChangelog({ changelogPath: cl, sourceDir });
|
|
45
|
+
|
|
46
|
+
expect(result.stagedFile).toBe(path.join(sourceDir, 'CHANGELOG.md'));
|
|
47
|
+
const staged = await fs.readFile(result.stagedFile, 'utf8');
|
|
48
|
+
expect(staged).toBe('# Hello\n');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('cleanup removes the staged file', async () => {
|
|
52
|
+
const sourceDir = path.join(tmpDir, 'src');
|
|
53
|
+
await fs.mkdir(sourceDir);
|
|
54
|
+
const cl = path.join(tmpDir, 'CHANGELOG.md');
|
|
55
|
+
await fs.writeFile(cl, '# Hello\n');
|
|
56
|
+
|
|
57
|
+
const result = await stagePromotedChangelog({ changelogPath: cl, sourceDir });
|
|
58
|
+
await result.cleanup();
|
|
59
|
+
|
|
60
|
+
await expect(fs.access(result.stagedFile)).rejects.toThrow();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('cleanup is idempotent', async () => {
|
|
64
|
+
const sourceDir = path.join(tmpDir, 'src');
|
|
65
|
+
await fs.mkdir(sourceDir);
|
|
66
|
+
const cl = path.join(tmpDir, 'CHANGELOG.md');
|
|
67
|
+
await fs.writeFile(cl, '# Hello\n');
|
|
68
|
+
|
|
69
|
+
const result = await stagePromotedChangelog({ changelogPath: cl, sourceDir });
|
|
70
|
+
await result.cleanup();
|
|
71
|
+
await expect(result.cleanup()).resolves.toBeUndefined();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('refuses to clobber an existing file in the source root', async () => {
|
|
75
|
+
const sourceDir = path.join(tmpDir, 'src');
|
|
76
|
+
await fs.mkdir(sourceDir);
|
|
77
|
+
const existing = path.join(sourceDir, 'CHANGELOG.md');
|
|
78
|
+
await fs.writeFile(existing, '# Original\n');
|
|
79
|
+
const cl = path.join(tmpDir, 'CHANGELOG.md');
|
|
80
|
+
await fs.writeFile(cl, '# Promoted\n');
|
|
81
|
+
|
|
82
|
+
const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
|
83
|
+
try {
|
|
84
|
+
const result = await stagePromotedChangelog({ changelogPath: cl, sourceDir });
|
|
85
|
+
expect(result.stagedFile).toBe(null);
|
|
86
|
+
// Existing file untouched
|
|
87
|
+
const content = await fs.readFile(existing, 'utf8');
|
|
88
|
+
expect(content).toBe('# Original\n');
|
|
89
|
+
expect(warn).toHaveBeenCalled();
|
|
90
|
+
} finally {
|
|
91
|
+
warn.mockRestore();
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Promote-changelog helper.
|
|
3
|
+
*
|
|
4
|
+
* Implements the `--promote-changelog=<file.md>` CLI option for `generate`
|
|
5
|
+
* and `serve`. The named markdown (or .mdx) file is staged into the source
|
|
6
|
+
* root before the build runs, so that it is rendered by the normal Ursa
|
|
7
|
+
* pipeline and ends up in the output root as a sibling of the main
|
|
8
|
+
* `index.html`. Once the build finishes (or the serve process exits) the
|
|
9
|
+
* staged copy is removed, leaving the source tree untouched.
|
|
10
|
+
*
|
|
11
|
+
* Behavior:
|
|
12
|
+
* - If no `--promote-changelog` value is provided, this is a no-op.
|
|
13
|
+
* - The staged filename is the basename of the supplied path (e.g.
|
|
14
|
+
* `CHANGELOG.md` becomes `<source>/CHANGELOG.md`).
|
|
15
|
+
* - If a file with the same basename already exists in the source root,
|
|
16
|
+
* no staging is performed and the existing file is left in place. A
|
|
17
|
+
* warning is logged so the user can resolve the conflict.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { promises as fs } from 'node:fs';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Stage a changelog file into the source root.
|
|
25
|
+
*
|
|
26
|
+
* @param {Object} args
|
|
27
|
+
* @param {string|null|undefined} args.changelogPath - Path supplied via --promote-changelog.
|
|
28
|
+
* @param {string} args.sourceDir - Resolved absolute path to the source directory.
|
|
29
|
+
* @returns {Promise<{ stagedFile: string|null, cleanup: () => Promise<void> }>}
|
|
30
|
+
*/
|
|
31
|
+
export async function stagePromotedChangelog({ changelogPath, sourceDir }) {
|
|
32
|
+
const noop = { stagedFile: null, cleanup: async () => {} };
|
|
33
|
+
if (!changelogPath) return noop;
|
|
34
|
+
|
|
35
|
+
const absolute = path.resolve(changelogPath);
|
|
36
|
+
let content;
|
|
37
|
+
try {
|
|
38
|
+
content = await fs.readFile(absolute, 'utf8');
|
|
39
|
+
} catch (err) {
|
|
40
|
+
throw new Error(
|
|
41
|
+
`--promote-changelog: cannot read file at ${absolute}: ${err.message}`
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const baseName = path.basename(absolute);
|
|
46
|
+
const stagedFile = path.join(sourceDir, baseName);
|
|
47
|
+
|
|
48
|
+
// Refuse to clobber an existing file in the source root.
|
|
49
|
+
let alreadyExists = false;
|
|
50
|
+
try {
|
|
51
|
+
await fs.access(stagedFile);
|
|
52
|
+
alreadyExists = true;
|
|
53
|
+
} catch {
|
|
54
|
+
// expected when the file does not exist
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (alreadyExists) {
|
|
58
|
+
console.warn(
|
|
59
|
+
`--promote-changelog: a file already exists at ${stagedFile}; leaving it in place.`
|
|
60
|
+
);
|
|
61
|
+
return noop;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
await fs.writeFile(stagedFile, content);
|
|
65
|
+
console.log(`--promote-changelog: staged ${absolute} -> ${stagedFile}`);
|
|
66
|
+
|
|
67
|
+
let cleanedUp = false;
|
|
68
|
+
const cleanup = async () => {
|
|
69
|
+
if (cleanedUp) return;
|
|
70
|
+
cleanedUp = true;
|
|
71
|
+
try {
|
|
72
|
+
await fs.unlink(stagedFile);
|
|
73
|
+
} catch (err) {
|
|
74
|
+
if (err.code !== 'ENOENT') {
|
|
75
|
+
console.warn(
|
|
76
|
+
`--promote-changelog: failed to remove staged file ${stagedFile}: ${err.message}`
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
return { stagedFile, cleanup };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Register process-exit handlers that invoke the given cleanup function.
|
|
87
|
+
* Used by long-running commands (serve, dev) so the staged file is removed
|
|
88
|
+
* even when the user stops the process with Ctrl-C.
|
|
89
|
+
*
|
|
90
|
+
* @param {() => Promise<void>} cleanup
|
|
91
|
+
*/
|
|
92
|
+
export function registerCleanupOnExit(cleanup) {
|
|
93
|
+
if (!cleanup) return;
|
|
94
|
+
let triggered = false;
|
|
95
|
+
const run = (exitCode) => {
|
|
96
|
+
if (triggered) return;
|
|
97
|
+
triggered = true;
|
|
98
|
+
Promise.resolve(cleanup())
|
|
99
|
+
.catch(() => {})
|
|
100
|
+
.finally(() => {
|
|
101
|
+
if (typeof exitCode === 'number') process.exit(exitCode);
|
|
102
|
+
});
|
|
103
|
+
};
|
|
104
|
+
process.once('exit', () => run());
|
|
105
|
+
process.once('SIGINT', () => run(130));
|
|
106
|
+
process.once('SIGTERM', () => run(143));
|
|
107
|
+
}
|