@kenjura/ursa 0.82.0 → 0.84.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,14 @@
1
+ # 0.84.0
2
+ 2026-05-08
3
+
4
+ - Unified sticky headers are now standard
5
+
6
+ # 0.83.0
7
+ 2026-05-07
8
+
9
+ - **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.
10
+ - **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.
11
+ - **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
12
  # 0.82.0
2
13
  2026-05-06
3
14
 
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,
@@ -1264,4 +1264,40 @@ footer#site-footer {
1264
1264
  -webkit-text-size-adjust: 100%;
1265
1265
  text-size-adjust: 100%;
1266
1266
  }
1267
+ }
1268
+
1269
+
1270
+ /* sticky unified header */
1271
+ article#main-content {
1272
+ h1, h2, h3 {
1273
+ position: sticky;
1274
+ z-index: 90;
1275
+ margin: 0;
1276
+
1277
+ &.stuck {
1278
+ background: var(--nav-top-bg);
1279
+ }
1280
+ }
1281
+ h1 {
1282
+ top: var(--global-nav-height);
1283
+ height: 3rem;
1284
+ line-height: 3rem;
1285
+ }
1286
+ h2 {
1287
+ top: calc(var(--global-nav-height) + 3rem);
1288
+ height: 2.25rem;
1289
+ line-height: 2.25rem;
1290
+ }
1291
+ h3 {
1292
+ top: calc(var(--global-nav-height) + 5.25rem);
1293
+ height: 1.75rem;
1294
+ line-height: 1.75rem;
1295
+ }
1296
+
1297
+ /* Hide stuck h2/h3 — the breadcrumb on the stuck h1 carries their text.
1298
+ Use visibility (not max-height/display) to preserve layout and avoid
1299
+ the flicker loop where collapsing the heading un-sticks it. */
1300
+ h2.stuck, h3.stuck {
1301
+ visibility: hidden;
1302
+ }
1267
1303
  }
@@ -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
@@ -2,7 +2,7 @@
2
2
  "name": "@kenjura/ursa",
3
3
  "author": "Andrew London <andrew@kenjura.com>",
4
4
  "type": "module",
5
- "version": "0.82.0",
5
+ "version": "0.84.0",
6
6
  "description": "static site generator from MD/wikitext/YML",
7
7
  "main": "lib/index.js",
8
8
  "bin": {
@@ -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
+ }