@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 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
@@ -2,7 +2,7 @@
2
2
  "name": "@kenjura/ursa",
3
3
  "author": "Andrew London <andrew@kenjura.com>",
4
4
  "type": "module",
5
- "version": "0.81.4",
5
+ "version": "0.83.0",
6
6
  "description": "static site generator from MD/wikitext/YML",
7
7
  "main": "lib/index.js",
8
8
  "bin": {
@@ -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' // MDX hydration control
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
+ }
@@ -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 url = docPathToUrl(docPath, sourceDir + '/');
577
- affectedUrlSet.add(url);
578
- if (viewedUrls.includes(url)) {
579
- priorityPaths.push(docPath);
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.map(p => docPathToUrl(p, sourceDir + '/')));
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.map(p => docPathToUrl(p, sourceDir + '/')));
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]);