@kenjura/ursa 0.86.0 → 0.88.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/bin/ursa.js CHANGED
@@ -123,6 +123,11 @@ yargs(hideBin(process.argv))
123
123
  describe: 'Port to serve on',
124
124
  type: 'number'
125
125
  })
126
+ .option('strict-port', {
127
+ describe: 'Fail if the port is taken instead of falling back to another',
128
+ type: 'boolean',
129
+ default: false
130
+ })
126
131
  .option('whitelist', {
127
132
  alias: 'w',
128
133
  describe: 'Path to whitelist file containing patterns for files to include',
@@ -177,7 +182,8 @@ yargs(hideBin(process.argv))
177
182
  port: port,
178
183
  _whitelist: whitelist,
179
184
  _exclude: exclude,
180
- _clean: clean
185
+ _clean: clean,
186
+ strictPort: argv['strict-port']
181
187
  });
182
188
  } catch (error) {
183
189
  console.error('Error starting development server:', error.message);
@@ -1,3 +1,4 @@
1
+ <!DOCTYPE html>
1
2
  <html>
2
3
 
3
4
  <head>
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.86.0",
5
+ "version": "0.88.0",
6
6
  "description": "static site generator from MD/wikitext/YML",
7
7
  "main": "lib/index.js",
8
8
  "bin": {
package/src/dev.js CHANGED
@@ -33,8 +33,9 @@ import { generateBreadcrumbs } from "./helper/breadcrumbs.js";
33
33
  import { extractImageReferences } from "./helper/imageExtractor.js";
34
34
  import { recurse } from "./helper/recursive-readdir.js";
35
35
  import { isFolderHidden, clearConfigCache } from "./helper/folderConfig.js";
36
+ import { isHiddenOrSystemPath, HIDDEN_OR_SYSTEM_DIRS_DEV } from "./helper/hiddenPaths.js";
36
37
  import { extractSections } from "./helper/sectionExtractor.js";
37
- import { getTemplates, getMenu, findAllCustomMenus, getCustomMenuForFile, getTransformedMetadata, getFooter, toTitleCase, addTrailingSlash, generateAutoIndexHtmlFromSource, copyMetaAssets } from "./helper/build/index.js";
38
+ import { getTemplates, getMenu, findAllCustomMenus, getCustomMenuForFile, getTransformedMetadata, getFooter, getUrsaMetadata, toTitleCase, addTrailingSlash, generateAutoIndexHtmlFromSource, copyMetaAssets } from "./helper/build/index.js";
38
39
  import { findCustomMenu, extractMenuFrontmatter, parseCustomMenu, combineAutoAndManualMenu } from "./helper/customMenu.js";
39
40
  import { getAndIncrementBuildId } from "./helper/ursaConfig.js";
40
41
  import { resolvePort } from "./helper/portUtils.js";
@@ -47,6 +48,9 @@ const devState = {
47
48
  source: null,
48
49
  meta: null,
49
50
  output: null,
51
+
52
+ // Ursa + doc repo versions, embedded in JSON response headers
53
+ ursaMetadata: null,
50
54
 
51
55
  // Background cache status
52
56
  cacheReady: false,
@@ -594,11 +598,14 @@ async function buildBackgroundCaches() {
594
598
  const allSourceFiles = await recurse(source, [() => false]);
595
599
 
596
600
  // Filter hidden folders
597
- const hiddenOrSystemDirs = /[\/\\]\.(?!\.)|[\/\\]node_modules[\/\\]/;
601
+ // Judged RELATIVE to the docroot — see helper/hiddenPaths.js for why
602
+ // testing the absolute path silently yields an empty site.
603
+ const isHiddenOrSystem = (f) =>
604
+ isHiddenOrSystemPath(f, source, HIDDEN_OR_SYSTEM_DIRS_DEV);
598
605
  const articleExtensions = /\.(md|mdx|txt|yml)/;
599
606
 
600
607
  const allArticles = allSourceFiles.filter(f =>
601
- f.match(articleExtensions) && !f.match(hiddenOrSystemDirs) && !isFolderHidden(dirname(f), source)
608
+ f.match(articleExtensions) && !isHiddenOrSystem(f) && !isFolderHidden(dirname(f), source)
602
609
  );
603
610
 
604
611
  const allDirectories = [];
@@ -606,7 +613,7 @@ async function buildBackgroundCaches() {
606
613
  for (const f of allSourceFiles) {
607
614
  try {
608
615
  const s = await stat(f);
609
- if (s.isDirectory() && !f.match(hiddenOrSystemDirs) && !isFolderHidden(f, source)) {
616
+ if (s.isDirectory() && !isHiddenOrSystem(f) && !isFolderHidden(f, source)) {
610
617
  if (!seenDirs.has(f)) {
611
618
  seenDirs.add(f);
612
619
  allDirectories.push(f);
@@ -620,7 +627,7 @@ async function buildBackgroundCaches() {
620
627
  let dir = dirname(article);
621
628
  while (dir.startsWith(source) && !seenDirs.has(dir)) {
622
629
  seenDirs.add(dir);
623
- if (!dir.match(hiddenOrSystemDirs)) {
630
+ if (!isHiddenOrSystem(dir)) {
624
631
  allDirectories.push(dir);
625
632
  }
626
633
  dir = dirname(dir);
@@ -766,6 +773,7 @@ export async function dev({
766
773
  devState.source = sourceDir + '/';
767
774
  devState.meta = metaDir;
768
775
  devState.output = outputDir + '/';
776
+ devState.ursaMetadata = await getUrsaMetadata(_source);
769
777
 
770
778
  console.log('🚀 Ursa Dev Mode');
771
779
  console.log('━'.repeat(50));
@@ -810,6 +818,17 @@ export async function dev({
810
818
  threshold: 1024,
811
819
  level: 6
812
820
  }));
821
+
822
+ // Add ursa-version and doc-version headers to all JSON responses
823
+ // (per-document JSON, directory index arrays, and public/*.json index files)
824
+ app.use((req, res, next) => {
825
+ if (req.path.endsWith('.json')) {
826
+ const meta = devState.ursaMetadata || {};
827
+ res.setHeader('X-ursa-version', meta.ursaVersion || 'unknown');
828
+ res.setHeader('X-doc-version', meta.docVersion || 'unknown');
829
+ }
830
+ next();
831
+ });
813
832
 
814
833
  // Dev mode document handler
815
834
  app.use(async (req, res, next) => {
@@ -0,0 +1,157 @@
1
+ import { join } from "path";
2
+ import { mkdtemp, rm, readFile } from "fs/promises";
3
+ import { existsSync } from "fs";
4
+ import { tmpdir } from "os";
5
+ import {
6
+ DependencyTracker,
7
+ loadDependencyTracker,
8
+ saveDependencyTracker,
9
+ getDependencyGraphPath,
10
+ } from "../dependencyTracker.js";
11
+
12
+ let tempDir;
13
+ beforeEach(async () => {
14
+ tempDir = await mkdtemp(join(tmpdir(), "ursa-deptracker-"));
15
+ });
16
+ afterEach(async () => {
17
+ await rm(tempDir, { recursive: true, force: true });
18
+ });
19
+
20
+ function makeTracker(sourceDir) {
21
+ const tracker = new DependencyTracker();
22
+ tracker.init(sourceDir);
23
+ return tracker;
24
+ }
25
+
26
+ describe("serialize / load", () => {
27
+ it("round-trips registrations through serialize + load", () => {
28
+ const t1 = makeTracker("/site/docs");
29
+ t1.registerDocument("/site/docs/a.md", {
30
+ templateName: "default-template",
31
+ cssPaths: ["/site/docs/style.css"],
32
+ scriptPaths: ["/site/docs/script.js"],
33
+ });
34
+ t1.registerDocument("/site/docs/sub/b.md", {
35
+ templateName: "wiki",
36
+ cssPaths: ["/site/docs/style.css", "/site/docs/sub/style.css"],
37
+ });
38
+
39
+ const data = JSON.parse(JSON.stringify(t1.serialize()));
40
+ const t2 = makeTracker("/site/docs");
41
+ expect(t2.load(data)).toBe(true);
42
+
43
+ expect([...t2.getAffectedDocuments("/site/docs/style.css")].sort()).toEqual([
44
+ "/site/docs/a.md",
45
+ "/site/docs/sub/b.md",
46
+ ]);
47
+ expect([...t2.getDocumentsUsingTemplate("wiki")]).toEqual(["/site/docs/sub/b.md"]);
48
+ expect(t2.getStats()).toEqual(t1.getStats());
49
+ });
50
+
51
+ it("merges with current-run registrations, which take precedence", () => {
52
+ const t1 = makeTracker("/site/docs");
53
+ t1.registerDocument("/site/docs/a.md", { templateName: "old-template" });
54
+ t1.registerDocument("/site/docs/b.md", { templateName: "default-template" });
55
+ const persisted = t1.serialize();
56
+
57
+ // New run: a.md was re-rendered with a different template before load
58
+ const t2 = makeTracker("/site/docs");
59
+ t2.registerDocument("/site/docs/a.md", { templateName: "new-template" });
60
+ expect(t2.load(persisted)).toBe(true);
61
+
62
+ // Live registration wins; persisted fills in the hash-skipped doc
63
+ expect([...t2.getDocumentsUsingTemplate("new-template")]).toEqual(["/site/docs/a.md"]);
64
+ expect([...t2.getDocumentsUsingTemplate("old-template")]).toEqual([]);
65
+ expect([...t2.getDocumentsUsingTemplate("default-template")]).toEqual(["/site/docs/b.md"]);
66
+ });
67
+
68
+ it("rejects mismatched schema versions and source dirs", () => {
69
+ const t = makeTracker("/site/docs");
70
+ expect(t.load(null)).toBe(false);
71
+ expect(t.load({ version: 99, documents: {} })).toBe(false);
72
+ const other = makeTracker("/different/source").serialize();
73
+ other.documents["/different/source/a.md"] = ["template:default-template"];
74
+ expect(t.load(other)).toBe(false);
75
+ expect(t.getStats().totalDocuments).toBe(0);
76
+ });
77
+ });
78
+
79
+ describe("prune", () => {
80
+ it("drops registrations for documents not in the keep set", () => {
81
+ const t = makeTracker("/site/docs");
82
+ t.registerDocument("/site/docs/keep.md", { cssPaths: ["/site/docs/style.css"] });
83
+ t.registerDocument("/site/docs/deleted.md", { cssPaths: ["/site/docs/style.css"] });
84
+
85
+ t.prune(new Set(["/site/docs/keep.md"]));
86
+
87
+ expect([...t.getAffectedDocuments("/site/docs/style.css")]).toEqual(["/site/docs/keep.md"]);
88
+ expect(t.getStats().totalDocuments).toBe(1);
89
+ });
90
+ });
91
+
92
+ describe("file persistence helpers", () => {
93
+ it("saves to and loads from .ursa/dependency-graph.json", async () => {
94
+ const t1 = makeTracker(tempDir);
95
+ t1.registerDocument(join(tempDir, "a.md"), {
96
+ templateName: "default-template",
97
+ cssPaths: [join(tempDir, "style.css")],
98
+ });
99
+ expect(await saveDependencyTracker(tempDir, t1)).toBe(true);
100
+ expect(existsSync(getDependencyGraphPath(tempDir))).toBe(true);
101
+ const onDisk = JSON.parse(await readFile(getDependencyGraphPath(tempDir), "utf8"));
102
+ expect(onDisk.version).toBe(1);
103
+
104
+ const t2 = makeTracker(tempDir);
105
+ expect(await loadDependencyTracker(tempDir, t2)).toBe(true);
106
+ expect([...t2.getAffectedDocuments(join(tempDir, "style.css"))]).toEqual([
107
+ join(tempDir, "a.md"),
108
+ ]);
109
+ });
110
+
111
+ it("returns false when no persisted graph exists", async () => {
112
+ const t = makeTracker(tempDir);
113
+ expect(await loadDependencyTracker(tempDir, t)).toBe(false);
114
+ });
115
+ });
116
+
117
+ describe("getMetaInvalidationPlan", () => {
118
+ it("does not force a full rebuild for static assets in meta", () => {
119
+ const t = makeTracker("/site/docs");
120
+ t.registerDocument("/site/docs/a.md", { templateName: "default-template" });
121
+
122
+ for (const file of ["logo.png", "font.woff2", "manual.pdf", "icon.SVG"]) {
123
+ const plan = t.getMetaInvalidationPlan(`/site/meta/shared/${file}`, "/site/meta");
124
+ expect(plan.requiresFullRebuild).toBe(false);
125
+ expect(plan.affectedDocuments).toEqual([]);
126
+ }
127
+ });
128
+
129
+ it("still regenerates documents for template and css/js meta changes", () => {
130
+ const t = makeTracker("/site/docs");
131
+ t.registerDocument("/site/docs/a.md", { templateName: "default-template" });
132
+
133
+ // New folder structure: templates/{name}/index.html → name from the folder
134
+ const tplPlan = t.getMetaInvalidationPlan(
135
+ "/site/meta/templates/default-template/index.html",
136
+ "/site/meta"
137
+ );
138
+ expect(tplPlan.requiresFullRebuild).toBe(false);
139
+ expect(tplPlan.affectedDocuments).toEqual(["/site/docs/a.md"]);
140
+
141
+ // Legacy flat structure: {name}.html at the meta root
142
+ const legacyPlan = t.getMetaInvalidationPlan(
143
+ "/site/meta/default-template.html",
144
+ "/site/meta"
145
+ );
146
+ expect(legacyPlan.requiresFullRebuild).toBe(false);
147
+ expect(legacyPlan.affectedDocuments).toEqual(["/site/docs/a.md"]);
148
+
149
+ const cssPlan = t.getMetaInvalidationPlan("/site/meta/shared/theme.css", "/site/meta");
150
+ expect(cssPlan.requiresFullRebuild).toBe(false);
151
+ expect(cssPlan.affectedDocuments).toEqual(["/site/docs/a.md"]);
152
+
153
+ // Unknown meta file types still fall back to a full rebuild
154
+ const unknownPlan = t.getMetaInvalidationPlan("/site/meta/shared/data.json", "/site/meta");
155
+ expect(unknownPlan.requiresFullRebuild).toBe(true);
156
+ });
157
+ });
@@ -0,0 +1,106 @@
1
+ import {
2
+ HIDDEN_OR_SYSTEM_DIRS,
3
+ HIDDEN_OR_SYSTEM_DIRS_DEV,
4
+ isHiddenOrSystemPath,
5
+ toSourceRelative,
6
+ } from "../hiddenPaths.js";
7
+
8
+ describe("toSourceRelative", () => {
9
+ it("strips the docroot and keeps a leading separator", () => {
10
+ expect(toSourceRelative("/srv/site/a/b.md", "/srv/site")).toBe("/a/b.md");
11
+ });
12
+
13
+ it("does not care whether the docroot has a trailing slash", () => {
14
+ expect(toSourceRelative("/srv/site/a.md", "/srv/site/")).toBe("/a.md");
15
+ expect(toSourceRelative("/srv/site/a.md", "/srv/site")).toBe("/a.md");
16
+ });
17
+
18
+ it("returns '/' for the docroot itself", () => {
19
+ expect(toSourceRelative("/srv/site", "/srv/site")).toBe("/");
20
+ expect(toSourceRelative("/srv/site/", "/srv/site")).toBe("/");
21
+ });
22
+
23
+ it("passes through a path outside the docroot unchanged", () => {
24
+ expect(toSourceRelative("/elsewhere/a.md", "/srv/site")).toBe(
25
+ "/elsewhere/a.md"
26
+ );
27
+ });
28
+ });
29
+
30
+ describe("isHiddenOrSystemPath", () => {
31
+ it("hides a dot-folder inside the docroot", () => {
32
+ expect(isHiddenOrSystemPath("/srv/site/.drafts/a.md", "/srv/site")).toBe(
33
+ true
34
+ );
35
+ });
36
+
37
+ it("hides a dot-folder nested inside the docroot", () => {
38
+ expect(isHiddenOrSystemPath("/srv/site/a/.x/b.md", "/srv/site")).toBe(true);
39
+ });
40
+
41
+ it("hides node_modules and _templates inside the docroot", () => {
42
+ expect(
43
+ isHiddenOrSystemPath("/srv/site/node_modules/p/a.md", "/srv/site")
44
+ ).toBe(true);
45
+ expect(isHiddenOrSystemPath("/srv/site/_templates/a.md", "/srv/site")).toBe(
46
+ true
47
+ );
48
+ });
49
+
50
+ it("does not hide an ordinary article", () => {
51
+ expect(isHiddenOrSystemPath("/srv/site/a/b.md", "/srv/site")).toBe(false);
52
+ });
53
+
54
+ /*
55
+ * The regression this module exists for.
56
+ *
57
+ * Every one of these docroots is perfectly ordinary; only its ANCESTRY
58
+ * contains a dot-directory. Testing the absolute path marked all of them
59
+ * hidden, so `generate` classified zero articles, reported success, and wrote
60
+ * an empty site.
61
+ */
62
+ it.each([
63
+ ["a git worktree", "/Users/x/repo/.claude/worktrees/wt/docs/help"],
64
+ ["a dotfile config dir", "/Users/x/.config/site"],
65
+ ["~/.local", "/Users/x/.local/share/site"],
66
+ ])("does not hide the docroot because of %s", (_label, root) => {
67
+ expect(isHiddenOrSystemPath(`${root}/index.md`, root)).toBe(false);
68
+ expect(isHiddenOrSystemPath(`${root}/api/action-api.md`, root)).toBe(false);
69
+ });
70
+
71
+ it("still hides a dot-folder inside a docroot that is itself under one", () => {
72
+ const root = "/Users/x/repo/.claude/worktrees/wt/docs/help";
73
+ expect(isHiddenOrSystemPath(`${root}/.drafts/a.md`, root)).toBe(true);
74
+ });
75
+
76
+ it("does not treat '..' as a hidden folder", () => {
77
+ expect(isHiddenOrSystemPath("/srv/site/a/../b.md", "/srv/site")).toBe(false);
78
+ });
79
+
80
+ it("accepts an alternative pattern for the dev server", () => {
81
+ const root = "/srv/site";
82
+ // The dev pattern omits _templates, which dev mode does not process.
83
+ expect(
84
+ isHiddenOrSystemPath(
85
+ `${root}/_templates/a.md`,
86
+ root,
87
+ HIDDEN_OR_SYSTEM_DIRS_DEV
88
+ )
89
+ ).toBe(false);
90
+ expect(
91
+ isHiddenOrSystemPath(`${root}/.x/a.md`, root, HIDDEN_OR_SYSTEM_DIRS_DEV)
92
+ ).toBe(true);
93
+ });
94
+
95
+ it("exports patterns that are not sticky or global", () => {
96
+ // A /g or /y regex would carry lastIndex between calls and answer
97
+ // differently on alternate invocations.
98
+ for (const re of [HIDDEN_OR_SYSTEM_DIRS, HIDDEN_OR_SYSTEM_DIRS_DEV]) {
99
+ expect(re.global).toBe(false);
100
+ expect(re.sticky).toBe(false);
101
+ }
102
+ const p = "/srv/site/.x/a.md";
103
+ expect(isHiddenOrSystemPath(p, "/srv/site")).toBe(true);
104
+ expect(isHiddenOrSystemPath(p, "/srv/site")).toBe(true);
105
+ });
106
+ });
@@ -0,0 +1,100 @@
1
+ import net from "net";
2
+ import { jest } from "@jest/globals";
3
+ import { isPortAvailable, resolvePort } from "../portUtils.js";
4
+
5
+ /** Hold a port for the duration of `fn`. */
6
+ async function withPortHeld(port, fn) {
7
+ const server = net.createServer();
8
+ await new Promise((resolve, reject) => {
9
+ server.once("error", reject);
10
+ server.listen(port, resolve);
11
+ });
12
+ try {
13
+ return await fn();
14
+ } finally {
15
+ await new Promise((resolve) => server.close(resolve));
16
+ }
17
+ }
18
+
19
+ /** A port pair (n, n+1) that is currently free, so tests do not fight the machine. */
20
+ async function findFreePair(start = 39000) {
21
+ for (let p = start; p < start + 400; p += 2) {
22
+ if ((await isPortAvailable(p)) && (await isPortAvailable(p + 1))) return p;
23
+ }
24
+ throw new Error("no free port pair for test");
25
+ }
26
+
27
+ describe("resolvePort", () => {
28
+ let logSpy;
29
+ beforeEach(() => {
30
+ logSpy = jest.spyOn(console, "log").mockImplementation(() => {});
31
+ });
32
+ afterEach(() => logSpy.mockRestore());
33
+
34
+ it("returns the requested port when it and its ws port are free", async () => {
35
+ const port = await findFreePair();
36
+ await expect(resolvePort(port, { strict: true })).resolves.toBe(port);
37
+ });
38
+
39
+ describe("strict", () => {
40
+ it("throws rather than moving when the HTTP port is taken", async () => {
41
+ const port = await findFreePair();
42
+ await withPortHeld(port, async () => {
43
+ await expect(resolvePort(port, { strict: true })).rejects.toThrow(
44
+ /already in use[\s\S]*--strict-port/
45
+ );
46
+ });
47
+ });
48
+
49
+ /*
50
+ * The WS port is the trap: ursa serves hot-reload on port+1, so a port pair
51
+ * is only usable if BOTH halves are free. A caller that checked only the
52
+ * HTTP port would call this fine and then die with EADDRINUSE later.
53
+ */
54
+ it("throws when only the WEBSOCKET port is taken", async () => {
55
+ const port = await findFreePair();
56
+ await withPortHeld(port + 1, async () => {
57
+ await expect(resolvePort(port, { strict: true })).rejects.toThrow(
58
+ /WebSocket port/
59
+ );
60
+ });
61
+ });
62
+ });
63
+
64
+ describe("non-interactive", () => {
65
+ /*
66
+ * The regression that motivated this: `ursa serve` running as one process
67
+ * of a parallel `pnpm dev`. Prompting there hangs the whole dev command,
68
+ * because sibling processes share stdin and nobody is reading this one.
69
+ */
70
+ it("falls back without prompting when stdin is not a TTY", async () => {
71
+ const port = await findFreePair();
72
+ const resolved = await withPortHeld(port, () =>
73
+ resolvePort(port, { interactive: false })
74
+ );
75
+ expect(resolved).not.toBe(port);
76
+ expect(typeof resolved).toBe("number");
77
+ });
78
+
79
+ it("says loudly which port it moved to", async () => {
80
+ const port = await findFreePair();
81
+ const resolved = await withPortHeld(port, () =>
82
+ resolvePort(port, { interactive: false })
83
+ );
84
+ const said = logSpy.mock.calls.flat().join("\n");
85
+ expect(said).toContain(String(resolved));
86
+ expect(said).toMatch(/not a TTY/);
87
+ // The whole point of being loud: whoever pointed at the old port must act.
88
+ expect(said).toMatch(/must be updated|--strict-port/);
89
+ });
90
+
91
+ it("prefers strict over falling back when both are in play", async () => {
92
+ const port = await findFreePair();
93
+ await withPortHeld(port, async () => {
94
+ await expect(
95
+ resolvePort(port, { strict: true, interactive: false })
96
+ ).rejects.toThrow(/--strict-port/);
97
+ });
98
+ });
99
+ });
100
+ });
@@ -1,4 +1,5 @@
1
1
  import dirTree from "directory-tree";
2
+ import { isHiddenOrSystemPath } from "./hiddenPaths.js";
2
3
  import { extname, basename, join, dirname } from "path";
3
4
  import { existsSync, readFileSync } from "fs";
4
5
  import { getFolderConfig, isFolderHidden, getRootConfig } from "./folderConfig.js";
@@ -447,10 +448,49 @@ function collapseSingleDocFolders(items) {
447
448
  });
448
449
  }
449
450
 
451
+ /**
452
+ * Drop hidden/system nodes from a directory-tree, judging each node's path
453
+ * RELATIVE to the docroot. See helper/hiddenPaths.js for why relative.
454
+ *
455
+ * Returns a new tree; the input is not mutated.
456
+ *
457
+ * @param {object} node - A directory-tree node
458
+ * @param {string} source - Absolute path of the docroot
459
+ * @returns {object} The pruned node
460
+ */
461
+ export function pruneHiddenNodes(node, source) {
462
+ if (!node.children) return node;
463
+ return {
464
+ ...node,
465
+ children: node.children
466
+ .filter((child) => !isHiddenOrSystemPath(child.path, source))
467
+ .map((child) => pruneHiddenNodes(child, source)),
468
+ };
469
+ }
470
+
450
471
  export async function getAutomenu(source, validPaths) {
451
- const tree = dirTree(source, {
452
- exclude: /[\/\\]\.|node_modules|_templates/, // Exclude hidden folders (starting with .), node_modules, and _templates
453
- });
472
+ /*
473
+ * Walk first, prune second.
474
+ *
475
+ * `dirTree`'s `exclude` is tested against each item's ABSOLUTE path,
476
+ * including the root's. A docroot that merely lives under a dot-directory —
477
+ * a git worktree under `.claude/worktrees/…`, anything in `~/.config` —
478
+ * therefore excluded ITSELF, and `dirTree` returned null, which reached
479
+ * `buildMenuData` as `Cannot read properties of null (reading 'children')`.
480
+ *
481
+ * Pruning afterwards judges each node relative to the docroot instead, which
482
+ * is what "hidden folder" was always meant to mean. The cost is that a
483
+ * `node_modules` sitting inside a docroot is now walked before being
484
+ * discarded; docroots do not normally contain one, and correctness on every
485
+ * ordinary path is worth more than speed on a pathological one.
486
+ */
487
+ const fullTree = dirTree(source);
488
+ if (!fullTree) {
489
+ throw new Error(
490
+ `Cannot read docroot for menu generation: ${source} (does it exist and is it a directory?)`
491
+ );
492
+ }
493
+ const tree = pruneHiddenNodes(fullTree, source);
454
494
 
455
495
  // Build menu data WITHOUT debug fields for smaller JSON
456
496
  let menuData = buildMenuData(tree, source, validPaths, '', false);
@@ -0,0 +1,95 @@
1
+ import { join } from "path";
2
+ import { mkdtemp, mkdir, writeFile, rm, readFile } from "fs/promises";
3
+ import { existsSync } from "fs";
4
+ import { tmpdir } from "os";
5
+ import { generateAutoIndices } from "../autoIndex.js";
6
+
7
+ let tempDir;
8
+ let source;
9
+ let output;
10
+ beforeEach(async () => {
11
+ tempDir = await mkdtemp(join(tmpdir(), "ursa-autoindex-"));
12
+ source = join(tempDir, "source");
13
+ output = join(tempDir, "output");
14
+ await mkdir(source, { recursive: true });
15
+ await mkdir(output, { recursive: true });
16
+ });
17
+ afterEach(async () => {
18
+ await rm(tempDir, { recursive: true, force: true });
19
+ });
20
+
21
+ const TEMPLATE =
22
+ "<html><head>${styleLink}</head><body>${menu}${body}${footer}${customScript}</body></html>";
23
+
24
+ function makeProgress() {
25
+ const logs = [];
26
+ return {
27
+ logs,
28
+ log: (msg) => logs.push(msg),
29
+ status: () => {},
30
+ done: () => {},
31
+ };
32
+ }
33
+
34
+ function runAutoIndices(directories, generatedArticles, progress) {
35
+ return generateAutoIndices(
36
+ output,
37
+ directories,
38
+ source,
39
+ { "default-template": TEMPLATE },
40
+ "",
41
+ "",
42
+ generatedArticles,
43
+ new Set(),
44
+ new Set(),
45
+ "20260101000000",
46
+ progress,
47
+ null
48
+ );
49
+ }
50
+
51
+ describe("generateAutoIndices with empty source folders", () => {
52
+ it("skips output directories that were never created instead of logging an error", async () => {
53
+ // Source has an empty folder (guides) and a folder with a document (docs).
54
+ // Only docs produced output files, so output/guides does not exist.
55
+ await mkdir(join(source, "guides"));
56
+ await mkdir(join(source, "docs"));
57
+ await writeFile(join(source, "docs", "hello.md"), "# Hello\n\nWorld\n");
58
+ await mkdir(join(output, "docs"));
59
+ await writeFile(join(output, "docs", "hello.html"), "<html><body>Hello</body></html>");
60
+
61
+ const progress = makeProgress();
62
+ await runAutoIndices(
63
+ [join(source, "guides"), join(source, "docs")],
64
+ [join(source, "docs", "hello.md")],
65
+ progress
66
+ );
67
+
68
+ const errors = progress.logs.filter((m) => /Error generating auto-index/i.test(m));
69
+ expect(errors).toEqual([]);
70
+ // The missing output directory is skipped, not created
71
+ expect(existsSync(join(output, "guides"))).toBe(false);
72
+ expect(existsSync(join(output, "guides", "index.html"))).toBe(false);
73
+ });
74
+
75
+ it("still generates auto-indices for folders that produced output", async () => {
76
+ await mkdir(join(source, "guides"));
77
+ await mkdir(join(source, "docs"));
78
+ await writeFile(join(source, "docs", "hello.md"), "# Hello\n\nWorld\n");
79
+ await mkdir(join(output, "docs"));
80
+ await writeFile(join(output, "docs", "hello.html"), "<html><body>Hello</body></html>");
81
+
82
+ const progress = makeProgress();
83
+ await runAutoIndices(
84
+ [join(source, "guides"), join(source, "docs")],
85
+ [join(source, "docs", "hello.md")],
86
+ progress
87
+ );
88
+
89
+ // Root and docs both exist in output, so both get an index.html
90
+ const docsIndex = await readFile(join(output, "docs", "index.html"), "utf8");
91
+ expect(docsIndex).toContain('<a href="hello.html">');
92
+ const rootIndex = await readFile(join(output, "index.html"), "utf8");
93
+ expect(rootIndex).toContain('<a href="docs/index.html">');
94
+ });
95
+ });