@kenjura/ursa 0.81.4 → 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 +12 -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__/frontmatterTable.test.js +88 -0
- package/src/helper/__test__/promoteChangelog.test.js +94 -0
- package/src/helper/frontmatterTable.js +21 -2
- package/src/helper/promoteChangelog.js +107 -0
- package/src/jobs/generate.js +18 -1
- package/src/serve.js +32 -6
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
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.
|
|
8
|
+
# 0.82.0
|
|
9
|
+
2026-05-06
|
|
10
|
+
|
|
11
|
+
- **Frontmatter table is now opt-in**: The HTML frontmatter table that was previously injected into every Markdown/MDX document after the first H1 is now only rendered when the document's frontmatter sets `render-frontmatter: true` (boolean `true` or string `"true"`). Documents without the flag (or with it set to `false`) no longer have the table injected. The `render-frontmatter` key itself is excluded from the rendered table.
|
|
12
|
+
|
|
1
13
|
# 0.81.4
|
|
2
14
|
2026-05-04
|
|
3
15
|
|
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,88 @@
|
|
|
1
|
+
import {
|
|
2
|
+
metadataToTable,
|
|
3
|
+
injectFrontmatterTable,
|
|
4
|
+
isRenderFrontmatterEnabled,
|
|
5
|
+
} from "../frontmatterTable.js";
|
|
6
|
+
|
|
7
|
+
describe("isRenderFrontmatterEnabled", () => {
|
|
8
|
+
it("returns false for missing metadata", () => {
|
|
9
|
+
expect(isRenderFrontmatterEnabled(null)).toBe(false);
|
|
10
|
+
expect(isRenderFrontmatterEnabled(undefined)).toBe(false);
|
|
11
|
+
expect(isRenderFrontmatterEnabled({})).toBe(false);
|
|
12
|
+
});
|
|
13
|
+
it("returns false when flag is missing", () => {
|
|
14
|
+
expect(isRenderFrontmatterEnabled({ title: "Foo" })).toBe(false);
|
|
15
|
+
});
|
|
16
|
+
it("returns false when flag is false", () => {
|
|
17
|
+
expect(isRenderFrontmatterEnabled({ "render-frontmatter": false })).toBe(false);
|
|
18
|
+
});
|
|
19
|
+
it("returns true when flag is boolean true", () => {
|
|
20
|
+
expect(isRenderFrontmatterEnabled({ "render-frontmatter": true })).toBe(true);
|
|
21
|
+
});
|
|
22
|
+
it("returns true when flag is the string 'true'", () => {
|
|
23
|
+
expect(isRenderFrontmatterEnabled({ "render-frontmatter": "true" })).toBe(true);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe("injectFrontmatterTable", () => {
|
|
28
|
+
const body = "<h1>Title</h1>\n<p>Body</p>";
|
|
29
|
+
|
|
30
|
+
it("does not inject when render-frontmatter flag is missing", () => {
|
|
31
|
+
const result = injectFrontmatterTable(body, { type: "power", cost: 3 });
|
|
32
|
+
expect(result).toBe(body);
|
|
33
|
+
expect(result).not.toContain("frontmatter-table");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("does not inject when render-frontmatter is false", () => {
|
|
37
|
+
const result = injectFrontmatterTable(body, {
|
|
38
|
+
"render-frontmatter": false,
|
|
39
|
+
type: "power",
|
|
40
|
+
});
|
|
41
|
+
expect(result).toBe(body);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("injects table after first H1 when render-frontmatter is true", () => {
|
|
45
|
+
const result = injectFrontmatterTable(body, {
|
|
46
|
+
"render-frontmatter": true,
|
|
47
|
+
type: "power",
|
|
48
|
+
cost: 3,
|
|
49
|
+
});
|
|
50
|
+
expect(result).toContain('<table class="frontmatter-table">');
|
|
51
|
+
expect(result).toContain("<th>Type</th>");
|
|
52
|
+
expect(result).toContain("<td>power</td>");
|
|
53
|
+
// Table appears after the </h1>
|
|
54
|
+
expect(result.indexOf("</h1>")).toBeLessThan(result.indexOf("frontmatter-table"));
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("accepts string 'true' for the flag", () => {
|
|
58
|
+
const result = injectFrontmatterTable(body, {
|
|
59
|
+
"render-frontmatter": "true",
|
|
60
|
+
type: "power",
|
|
61
|
+
});
|
|
62
|
+
expect(result).toContain('<table class="frontmatter-table">');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("does not include render-frontmatter itself as a row", () => {
|
|
66
|
+
const result = injectFrontmatterTable(body, {
|
|
67
|
+
"render-frontmatter": true,
|
|
68
|
+
type: "power",
|
|
69
|
+
});
|
|
70
|
+
expect(result).not.toContain(">Render Frontmatter<");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("returns body unchanged when no displayable entries exist", () => {
|
|
74
|
+
const result = injectFrontmatterTable(body, {
|
|
75
|
+
"render-frontmatter": true,
|
|
76
|
+
title: "ignored",
|
|
77
|
+
});
|
|
78
|
+
expect(result).toBe(body);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe("metadataToTable", () => {
|
|
83
|
+
it("still produces a table when called directly (used by other consumers)", () => {
|
|
84
|
+
const html = metadataToTable({ type: "power", cost: 3 });
|
|
85
|
+
expect(html).toContain('<table class="frontmatter-table">');
|
|
86
|
+
expect(html).toContain("<th>Type</th>");
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -3,6 +3,18 @@
|
|
|
3
3
|
* and inject it into the document body after the first H1
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* Determine whether the document's frontmatter opts in to rendering
|
|
8
|
+
* the frontmatter table. Accepts boolean true or the string "true".
|
|
9
|
+
* @param {Object} metadata
|
|
10
|
+
* @returns {boolean}
|
|
11
|
+
*/
|
|
12
|
+
export function isRenderFrontmatterEnabled(metadata) {
|
|
13
|
+
if (!metadata || typeof metadata !== 'object') return false;
|
|
14
|
+
const value = metadata['render-frontmatter'];
|
|
15
|
+
return value === true || value === 'true';
|
|
16
|
+
}
|
|
17
|
+
|
|
6
18
|
/**
|
|
7
19
|
* Convert a metadata value to a displayable string
|
|
8
20
|
* @param {any} value - The value to convert
|
|
@@ -75,7 +87,8 @@ export function metadataToTable(metadata) {
|
|
|
75
87
|
'generate-auto-index', // Auto-indexing control
|
|
76
88
|
'auto-index-depth', // Auto-indexing depth
|
|
77
89
|
'auto-index-position', // Auto-indexing position
|
|
78
|
-
'hydrate'
|
|
90
|
+
'hydrate', // MDX hydration control
|
|
91
|
+
'render-frontmatter' // Opt-in flag controlling whether this table is rendered
|
|
79
92
|
];
|
|
80
93
|
const entries = Object.entries(metadata).filter(
|
|
81
94
|
([key]) => !excludeKeys.includes(key.toLowerCase())
|
|
@@ -109,8 +122,14 @@ ${rows}
|
|
|
109
122
|
* @returns {string} The body HTML with the frontmatter table injected
|
|
110
123
|
*/
|
|
111
124
|
export function injectFrontmatterTable(bodyHtml, metadata) {
|
|
125
|
+
// Opt-in: only render the frontmatter table when the document explicitly
|
|
126
|
+
// sets `render-frontmatter: true` in its frontmatter.
|
|
127
|
+
if (!metadata || !isRenderFrontmatterEnabled(metadata)) {
|
|
128
|
+
return bodyHtml;
|
|
129
|
+
}
|
|
130
|
+
|
|
112
131
|
const table = metadataToTable(metadata);
|
|
113
|
-
|
|
132
|
+
|
|
114
133
|
if (!table) {
|
|
115
134
|
return bodyHtml;
|
|
116
135
|
}
|
|
@@ -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
|
+
}
|
package/src/jobs/generate.js
CHANGED
|
@@ -1583,7 +1583,24 @@ export async function regenerateSingleFile(changedFile, {
|
|
|
1583
1583
|
const xmlOutputFilename = outputFilename.replace(".html", ".xml");
|
|
1584
1584
|
const xml = `<article>${o2x(jsonObject)}</article>`;
|
|
1585
1585
|
await outputFile(xmlOutputFilename, xml);
|
|
1586
|
-
|
|
1586
|
+
|
|
1587
|
+
// Folder-named index promotion (mirrors autoIndex behavior on full builds):
|
|
1588
|
+
// If the file is named like its parent folder (e.g. aletheia/aletheia.md) and
|
|
1589
|
+
// no explicit index.{md,mdx,txt,yml,html} exists alongside it, also write the
|
|
1590
|
+
// same outputs to <dir>/index.html|json|xml so the canonical URL stays fresh.
|
|
1591
|
+
const sourceDirOfFile = dirname(changedFile);
|
|
1592
|
+
const parentFolderName = basename(sourceDirOfFile);
|
|
1593
|
+
if (base && parentFolderName && base === parentFolderName) {
|
|
1594
|
+
const hasExplicitIndex = ['index.md', 'index.mdx', 'index.txt', 'index.yml', 'index.html']
|
|
1595
|
+
.some(name => existsSync(join(sourceDirOfFile, name)));
|
|
1596
|
+
if (!hasExplicitIndex) {
|
|
1597
|
+
const outDirOfFile = dirname(outputFilename);
|
|
1598
|
+
await outputFile(join(outDirOfFile, 'index.html'), finalHtml);
|
|
1599
|
+
await outputFile(join(outDirOfFile, 'index.json'), json);
|
|
1600
|
+
await outputFile(join(outDirOfFile, 'index.xml'), xml);
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1587
1604
|
// Update hash cache
|
|
1588
1605
|
updateHash(changedFile, rawBody, hashCache);
|
|
1589
1606
|
|
package/src/serve.js
CHANGED
|
@@ -69,6 +69,30 @@ function docPathToUrl(docPath, sourceDir) {
|
|
|
69
69
|
return normalizeUrl(rawUrl);
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
/**
|
|
73
|
+
* Return all URL paths a source file maps to. Most files map to a single URL,
|
|
74
|
+
* but folder-named files (e.g. aletheia/aletheia.md) are promoted to
|
|
75
|
+
* <folder>/index.html during the build, so they also serve the folder URL.
|
|
76
|
+
* @param {string} docPath - Absolute source path
|
|
77
|
+
* @param {string} sourceDir - Absolute source directory (with trailing slash)
|
|
78
|
+
* @returns {string[]} Normalized URL paths
|
|
79
|
+
*/
|
|
80
|
+
function docPathToUrls(docPath, sourceDir) {
|
|
81
|
+
const urls = [docPathToUrl(docPath, sourceDir)];
|
|
82
|
+
const ext = docPath.match(/\.(md|mdx|txt|yml|yaml)$/);
|
|
83
|
+
if (ext) {
|
|
84
|
+
const base = basename(docPath, ext[0]);
|
|
85
|
+
const parent = basename(dirname(docPath));
|
|
86
|
+
if (base && parent && base === parent) {
|
|
87
|
+
// Folder-named file → also serves the folder's index URL
|
|
88
|
+
const folderUrl = normalizeUrl('/' + dirname(docPath).replace(
|
|
89
|
+
sourceDir.endsWith('/') ? sourceDir : sourceDir + '/', '') + '/');
|
|
90
|
+
if (!urls.includes(folderUrl)) urls.push(folderUrl);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return urls;
|
|
94
|
+
}
|
|
95
|
+
|
|
72
96
|
/**
|
|
73
97
|
* Broadcast a message to all connected WebSocket clients.
|
|
74
98
|
* @param {object} messageObj - Object to JSON.stringify and send
|
|
@@ -573,10 +597,12 @@ export async function serve({
|
|
|
573
597
|
const affectedUrlSet = new Set();
|
|
574
598
|
|
|
575
599
|
for (const docPath of docPathsArray) {
|
|
576
|
-
const
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
priorityPaths.
|
|
600
|
+
const urls = docPathToUrls(docPath, sourceDir + '/');
|
|
601
|
+
for (const url of urls) {
|
|
602
|
+
affectedUrlSet.add(url);
|
|
603
|
+
if (viewedUrls.includes(url) && !priorityPaths.includes(docPath)) {
|
|
604
|
+
priorityPaths.push(docPath);
|
|
605
|
+
}
|
|
580
606
|
}
|
|
581
607
|
}
|
|
582
608
|
|
|
@@ -599,7 +625,7 @@ export async function serve({
|
|
|
599
625
|
onPriorityComplete: ({ regenerated, failed, priorityDocs }) => {
|
|
600
626
|
if (regenerated > 0) {
|
|
601
627
|
// Immediately reload clients whose pages are now ready
|
|
602
|
-
const readyUrls = new Set(priorityDocs.
|
|
628
|
+
const readyUrls = new Set(priorityDocs.flatMap(p => docPathToUrls(p, sourceDir + '/')));
|
|
603
629
|
console.log(`⚡ Priority complete: ${regenerated} OK, ${failed} failed → reloading clients`);
|
|
604
630
|
reloadAffectedClients(readyUrls, uniqueNames[0]);
|
|
605
631
|
} else if (failed > 0) {
|
|
@@ -610,7 +636,7 @@ export async function serve({
|
|
|
610
636
|
|
|
611
637
|
// After all remaining docs are done, reload any remaining affected clients
|
|
612
638
|
// (non-priority clients that weren't reloaded during onPriorityComplete)
|
|
613
|
-
const priorityUrlSet = new Set(priorityPaths.
|
|
639
|
+
const priorityUrlSet = new Set(priorityPaths.flatMap(p => docPathToUrls(p, sourceDir + '/')));
|
|
614
640
|
const remainingUrls = new Set([...affectedUrlSet].filter(u => !priorityUrlSet.has(u)));
|
|
615
641
|
if (remainingUrls.size > 0) {
|
|
616
642
|
reloadAffectedClients(remainingUrls, uniqueNames[0]);
|