@kenjura/ursa 0.96.0 → 0.98.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.
Files changed (48) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README.md +144 -16
  3. package/bin/ursa.js +14 -1
  4. package/meta/templates/default-template/default.css +144 -0
  5. package/meta/templates/default-template/menu.js +18 -1
  6. package/meta/templates/default-template/search.js +11 -0
  7. package/meta/templates/default-template/sectionify.js +17 -9
  8. package/meta/templates/default-template/widgets.js +4 -0
  9. package/package.json +1 -2
  10. package/src/dev.js +13 -23
  11. package/src/helper/__test__/contentHash.test.js +16 -6
  12. package/src/helper/__test__/inlineMenu.test.js +142 -0
  13. package/src/helper/assetBundler.js +93 -19
  14. package/src/helper/automenu.js +39 -13
  15. package/src/helper/build/__test__/autoIndex.test.js +2 -132
  16. package/src/helper/build/__test__/graph.test.js +259 -3
  17. package/src/helper/build/__test__/pass.test.js +664 -0
  18. package/src/helper/build/autoIndex.js +6 -371
  19. package/src/helper/build/excludeFilter.js +1 -2
  20. package/src/helper/build/footer.js +27 -14
  21. package/src/helper/build/graph.js +575 -152
  22. package/src/helper/build/index.js +0 -2
  23. package/src/helper/build/metadata.js +19 -5
  24. package/src/helper/build/pass.js +497 -0
  25. package/src/helper/build/precedence.js +174 -0
  26. package/src/helper/build/site.js +1392 -0
  27. package/src/helper/build/templates.js +1 -2
  28. package/src/helper/build/tracedFs.js +247 -0
  29. package/src/helper/contentHash.js +0 -78
  30. package/src/helper/customMenu.js +27 -4
  31. package/src/helper/fileRenderer.js +119 -111
  32. package/src/helper/findScriptJs.js +1 -1
  33. package/src/helper/findStyleCss.js +1 -1
  34. package/src/helper/folderConfig.js +7 -18
  35. package/src/helper/fullTextIndex.js +41 -29
  36. package/src/helper/imageProcessor.js +45 -0
  37. package/src/helper/inlineMenu.js +275 -0
  38. package/src/helper/linkValidator.js +118 -127
  39. package/src/helper/mdxRenderer.js +27 -5
  40. package/src/helper/menuLabels.js +30 -5
  41. package/src/helper/whitelistFilter.js +1 -2
  42. package/src/jobs/generate.js +67 -1829
  43. package/src/serve.js +317 -697
  44. package/src/helper/__test__/dependencyTracker.test.js +0 -157
  45. package/src/helper/build/cacheBust.js +0 -141
  46. package/src/helper/build/navCache.js +0 -145
  47. package/src/helper/build/watchCache.js +0 -33
  48. package/src/helper/dependencyTracker.js +0 -384
@@ -2,12 +2,22 @@ import { join } from "path";
2
2
  import { mkdtemp, rm, mkdir, writeFile, readFile } from "fs/promises";
3
3
  import { existsSync } from "fs";
4
4
  import { tmpdir } from "os";
5
- import {
6
- enforceCacheVersion,
7
- getUrsaDir,
8
- loadHashCache,
9
- saveHashCache,
10
- } from "../contentHash.js";
5
+ import { enforceCacheVersion, getUrsaDir } from "../contentHash.js";
6
+
7
+ // The cache `.ursa/` holds is the build graph. These tests only need a file
8
+ // that must survive a matching stamp and vanish on a mismatch.
9
+ const CACHE_FILE = "graph.json";
10
+ async function saveHashCache(dir, map) {
11
+ await mkdir(getUrsaDir(dir), { recursive: true });
12
+ await writeFile(join(getUrsaDir(dir), CACHE_FILE), JSON.stringify([...map]));
13
+ }
14
+ async function loadHashCache(dir) {
15
+ try {
16
+ return new Map(JSON.parse(await readFile(join(getUrsaDir(dir), CACHE_FILE), "utf8")));
17
+ } catch {
18
+ return new Map();
19
+ }
20
+ }
11
21
 
12
22
  let sourceDir;
13
23
  beforeEach(async () => {
@@ -0,0 +1,142 @@
1
+ import { join } from "path";
2
+ import { mkdtemp, mkdir, writeFile, rm } from "fs/promises";
3
+ import { tmpdir } from "os";
4
+ import {
5
+ namedMenuOptions,
6
+ findNamedMenu,
7
+ collectMenuAnchorIds,
8
+ prepareMdxMenuAnchors,
9
+ resolveMenuAnchors,
10
+ renderInlineMenuHtml,
11
+ menuNotFoundComment,
12
+ leadingMenusEnd,
13
+ } from "../inlineMenu.js";
14
+ import { isMenuFile, findCustomMenu } from "../customMenu.js";
15
+
16
+ describe("isMenuFile", () => {
17
+ it("matches the folder menu and named menus, not documents", () => {
18
+ for (const name of ["menu.md", "_menu.md", "menu.txt", "menu-2.md", "menu-classes.md", "_menu-x.txt", "Menu-Classes.md"]) {
19
+ expect(isMenuFile(name)).toBe(true);
20
+ }
21
+ for (const name of ["menus.md", "menu.mdx", "my-menu.md", "menu-.md", "menu_x.md", "index.md", "menu"]) {
22
+ expect(isMenuFile(name)).toBe(false);
23
+ }
24
+ });
25
+ });
26
+
27
+ describe("namedMenuOptions", () => {
28
+ it("defaults appearance to horizontal and reports an invalid value", () => {
29
+ expect(namedMenuOptions({ id: "classes" })).toEqual({ id: "classes", appearance: "horizontal", appearanceInvalid: null });
30
+ expect(namedMenuOptions({ id: "x", appearance: "Vertical" }).appearance).toBe("vertical");
31
+ expect(namedMenuOptions({ id: "x", appearance: "sideways" })).toMatchObject({ appearance: "horizontal", appearanceInvalid: "sideways" });
32
+ expect(namedMenuOptions({}).id).toBeNull();
33
+ expect(namedMenuOptions({ id: "" }).id).toBeNull();
34
+ });
35
+ });
36
+
37
+ describe("anchors", () => {
38
+ it("collects ids in both forms, once each", () => {
39
+ const html = '<p>{menu:a}</p><div data-ursa-menu="b"></div><p>x {menu:a} y</p>';
40
+ expect(collectMenuAnchorIds(html)).toEqual(["b", "a"]);
41
+ });
42
+
43
+ it("rewrites MDX anchors alone on a line into the element form", () => {
44
+ const src = "---\nx: 1\n---\n\n{menu:classes}\n\nText with {menu:inline} stays.\n {menu:indented} \n";
45
+ const out = prepareMdxMenuAnchors(src);
46
+ expect(out).toContain('<div data-ursa-menu="classes"></div>');
47
+ expect(out).toContain('<div data-ursa-menu="indented"></div>');
48
+ expect(out).toContain("Text with {menu:inline} stays.");
49
+ });
50
+
51
+ it("replaces a paragraph that is only the anchor", () => {
52
+ const out = resolveMenuAnchors("<h1>T</h1>\n<p>{menu:classes}</p>\n<p>Body.</p>", (id) => `<nav>${id}</nav>`);
53
+ expect(out).toBe("<h1>T</h1>\n<nav>classes</nav>\n<p>Body.</p>");
54
+ });
55
+
56
+ it("splits a paragraph with text around the anchor so the nav is not inside a <p>", () => {
57
+ const out = resolveMenuAnchors("<p>Before {menu:x} after</p>", (id) => `<nav>${id}</nav>`);
58
+ expect(out).toBe("<p>Before</p>\n<nav>x</nav><p>after</p>\n");
59
+ });
60
+
61
+ it("leaves anchors quoted in code alone", () => {
62
+ const html = "<p>Write <code>{menu:x}</code> on its own line.</p>\n<pre><code>{menu:y}</code></pre>";
63
+ expect(resolveMenuAnchors(html, () => "NO")).toBe(html);
64
+ });
65
+
66
+ it("replaces the element form anywhere", () => {
67
+ const out = resolveMenuAnchors('<div data-ursa-menu="c"></div><div data-ursa-menu="d"/>', (id) => `[${id}]`);
68
+ expect(out).toBe("[c][d]");
69
+ });
70
+
71
+ it("is a no-op for bodies without anchors", () => {
72
+ const html = "<p>Nothing here { menu } either</p>";
73
+ expect(resolveMenuAnchors(html, () => "X")).toBe(html);
74
+ });
75
+
76
+ it("finds where leading menus end", () => {
77
+ const nav = '<nav class="ursa-menu ursa-menu-horizontal" data-menu-id="a"><ul></ul></nav>';
78
+ expect(leadingMenusEnd(`${nav}\n<p>x</p>`)).toBe(nav.length);
79
+ expect(leadingMenusEnd(`\n${nav}${menuNotFoundComment("b")}<h1>T</h1>`)).toBe(1 + nav.length + menuNotFoundComment("b").length);
80
+ expect(leadingMenusEnd("<h1>T</h1>")).toBe(0);
81
+ });
82
+ });
83
+
84
+ describe("renderInlineMenuHtml", () => {
85
+ const data = [
86
+ { label: "Arcanist", href: "/character/classes/arcanist.html", children: [] },
87
+ { label: "Fighter & Co", href: "/character/classes/fighter.html", children: [] },
88
+ {
89
+ label: "More",
90
+ href: null,
91
+ children: [{ label: "Witch", href: "/character/classes/witch.html", children: [] }],
92
+ },
93
+ ];
94
+
95
+ it("renders a horizontal nav by default, escaping labels", () => {
96
+ const html = renderInlineMenuHtml(data, { id: "classes" });
97
+ expect(html).toMatch(/^<nav class="ursa-menu ursa-menu-horizontal" data-menu-id="classes"/);
98
+ expect(html).toContain("Fighter &amp; Co");
99
+ expect(html).toContain('<ul class="ursa-menu-level" data-depth="1">');
100
+ expect(html).toContain('<li class="ursa-menu-item ursa-menu-has-children"><span>More</span>');
101
+ });
102
+
103
+ it("marks the current page and its ancestors", () => {
104
+ const html = renderInlineMenuHtml(data, { id: "classes", appearance: "vertical", currentUrl: "/character/classes/witch.html" });
105
+ expect(html).toContain("ursa-menu-vertical");
106
+ expect(html).toContain('<li class="ursa-menu-item ursa-menu-current"><a href="/character/classes/witch.html" aria-current="page">Witch</a>');
107
+ expect(html).toContain('<li class="ursa-menu-item ursa-menu-has-children ursa-menu-active"><span>More</span>');
108
+ expect(html).not.toContain('ursa-menu-current"><a href="/character/classes/arcanist.html"');
109
+ });
110
+
111
+ it("treats index.html, a trailing slash and no extension as the same page", () => {
112
+ const items = [{ label: "Classes", href: "/character/classes/index.html", children: [] }];
113
+ for (const url of ["/character/classes/", "/character/classes", "/character/classes/index.html"]) {
114
+ expect(renderInlineMenuHtml(items, { id: "x", currentUrl: url })).toContain("ursa-menu-current");
115
+ }
116
+ });
117
+ });
118
+
119
+ describe("findNamedMenu / findCustomMenu", () => {
120
+ let root;
121
+ beforeEach(async () => {
122
+ root = await mkdtemp(join(tmpdir(), "ursa-inline-menu-"));
123
+ await mkdir(join(root, "a/b"), { recursive: true });
124
+ await writeFile(join(root, "menu.md"), "---\nauto-generate-menu: true\n---\n");
125
+ await writeFile(join(root, "menu-classes.md"), "---\nid: classes\n---\n- [Root](./index.md)\n");
126
+ await writeFile(join(root, "a/menu-classes.md"), "---\nid: classes\nappearance: vertical\n---\n- [Deep](./x.md)\n");
127
+ await writeFile(join(root, "a/b/menu.md"), "---\nid: named-main\n---\n- [Hidden](./y.md)\n");
128
+ });
129
+ afterEach(() => rm(root, { recursive: true, force: true }));
130
+
131
+ it("nearest file with the id wins", () => {
132
+ expect(findNamedMenu(join(root, "a/b"), root, "classes").menuDir).toBe(join(root, "a"));
133
+ expect(findNamedMenu(root, root, "classes").menuDir).toBe(root);
134
+ expect(findNamedMenu(join(root, "a/b"), root, "named-main").path).toBe(join(root, "a/b/menu.md"));
135
+ expect(findNamedMenu(join(root, "a/b"), root, "nope")).toBeNull();
136
+ });
137
+
138
+ it("a menu.md with an id is not the folder's nav menu", () => {
139
+ const info = findCustomMenu(join(root, "a/b"), root);
140
+ expect(info.menuDir).toBe(root);
141
+ });
142
+ });
@@ -13,8 +13,8 @@
13
13
 
14
14
  import * as esbuild from "esbuild";
15
15
  import { join, dirname, basename, relative, resolve } from "path";
16
- import { readFile, writeFile, mkdir } from "fs/promises";
17
- import { existsSync } from "fs";
16
+ import { writeFile, mkdir } from "fs/promises";
17
+ import { readFile, existsSync } from "./build/tracedFs.js";
18
18
  import { outputFile } from "fs-extra";
19
19
 
20
20
  // Cache for meta bundles so we don't rebuild them per-document
@@ -68,10 +68,13 @@ export function parseTemplateAssets(templateHtml) {
68
68
  * @param {string} templateHtml - Original template HTML
69
69
  * @param {string} templateName - Template name (used for bundle filename)
70
70
  * @param {{ cssFiles: string[], jsFiles: string[], cdnCss: string[], cdnJs: string[] }} assets - Parsed assets
71
+ * @param {{cssUrl?: string, jsUrl?: string}} [urls] - Bundle URLs to emit (default: /public/<name>.bundle.*)
71
72
  * @returns {string} Rewritten template HTML
72
73
  */
73
- export function rewriteTemplateWithBundles(templateHtml, templateName, assets) {
74
+ export function rewriteTemplateWithBundles(templateHtml, templateName, assets, urls = {}) {
74
75
  let html = templateHtml;
76
+ const cssUrl = urls.cssUrl ?? `/public/${templateName}.bundle.css`;
77
+ const jsUrl = urls.jsUrl ?? `/public/${templateName}.bundle.js`;
75
78
 
76
79
  // Replace individual CSS <link> tags with a single bundle reference
77
80
  if (assets.cssFiles.length > 0) {
@@ -86,7 +89,7 @@ export function rewriteTemplateWithBundles(templateHtml, templateName, assets) {
86
89
  html = html.replace(pattern, "\n");
87
90
  }
88
91
  // Insert single bundle link where the first CSS link was (in <head>)
89
- const bundleCssTag = ` <link rel="stylesheet" href="/public/${templateName}.bundle.css" />`;
92
+ const bundleCssTag = ` <link rel="stylesheet" href="${cssUrl}" />`;
90
93
  // Insert after the last CDN CSS or at the position of the first removed tag
91
94
  // Best heuristic: insert right before ${styleLink} or before </head>
92
95
  if (html.includes("${styleLink}")) {
@@ -107,7 +110,7 @@ export function rewriteTemplateWithBundles(templateHtml, templateName, assets) {
107
110
  html = html.replace(pattern, "\n");
108
111
  }
109
112
  // Insert single bundle script before ${customScript} or before </body>
110
- const bundleJsTag = ` <script src="/public/${templateName}.bundle.js"></script>`;
113
+ const bundleJsTag = ` <script src="${jsUrl}"></script>`;
111
114
  if (html.includes("${customScript}")) {
112
115
  html = html.replace("${customScript}", bundleJsTag + "\n ${customScript}");
113
116
  } else {
@@ -192,6 +195,19 @@ function rebaseCssUrls(css, cssFileDir, sourceDir) {
192
195
  */
193
196
  export async function bundleCss(filePaths, outputPath, { minify = true, rebaseUrls = false, sourceDir = "" } = {}) {
194
197
  if (filePaths.length === 0) return;
198
+ const code = await bundleCssContent(filePaths, { minify, rebaseUrls, sourceDir });
199
+ await mkdir(dirname(outputPath), { recursive: true });
200
+ await writeFile(outputPath, code);
201
+ }
202
+
203
+ /**
204
+ * Bundle CSS files and return the result instead of writing it.
205
+ * @param {string[]} filePaths - Absolute paths to CSS files to bundle
206
+ * @param {{ minify?: boolean, rebaseUrls?: boolean, sourceDir?: string }} options
207
+ * @returns {Promise<string>} Bundled (and, when possible, minified) CSS
208
+ */
209
+ export async function bundleCssContent(filePaths, { minify = true, rebaseUrls = false, sourceDir = "" } = {}) {
210
+ if (filePaths.length === 0) return "";
195
211
 
196
212
  const allImports = [];
197
213
  const allRules = [];
@@ -224,17 +240,14 @@ export async function bundleCss(filePaths, outputPath, { minify = true, rebaseUr
224
240
  loader: "css",
225
241
  minify: true,
226
242
  });
227
- await mkdir(dirname(outputPath), { recursive: true });
228
- await writeFile(outputPath, result.code);
229
- return;
243
+ return result.code;
230
244
  } catch (e) {
231
245
  console.warn(`⚠️ CSS minification failed, using unminified bundle: ${e.message}`);
232
246
  }
233
247
  }
234
248
 
235
- // Fallback: write raw concatenated CSS
236
- await mkdir(dirname(outputPath), { recursive: true });
237
- await writeFile(outputPath, combined);
249
+ // Fallback: raw concatenated CSS
250
+ return combined;
238
251
  }
239
252
 
240
253
  /**
@@ -251,6 +264,22 @@ export async function bundleCss(filePaths, outputPath, { minify = true, rebaseUr
251
264
  */
252
265
  export async function bundleJs(filePaths, outputPath, { minify = true, minifySyntax = true } = {}) {
253
266
  if (filePaths.length === 0) return { success: false };
267
+ const { success, code } = await bundleJsContent(filePaths, { minify, minifySyntax });
268
+ if (!success) return { success: false };
269
+ await mkdir(dirname(outputPath), { recursive: true });
270
+ await writeFile(outputPath, code);
271
+ return { success: true };
272
+ }
273
+
274
+ /**
275
+ * Bundle JS files and return the result instead of writing it.
276
+ * @param {string[]} filePaths - Absolute paths to JS files to bundle
277
+ * @param {{ minify?: boolean, minifySyntax?: boolean }} options
278
+ * @returns {Promise<{ success: boolean, code: string }>} success is false when the
279
+ * concatenation has a syntax error (callers keep individual tags)
280
+ */
281
+ export async function bundleJsContent(filePaths, { minify = true, minifySyntax = true } = {}) {
282
+ if (filePaths.length === 0) return { success: false, code: "" };
254
283
 
255
284
  // Concatenate all JS files with separators
256
285
  const contents = [];
@@ -268,9 +297,7 @@ export async function bundleJs(filePaths, outputPath, { minify = true, minifySyn
268
297
  minify: true,
269
298
  minifySyntax,
270
299
  });
271
- await mkdir(dirname(outputPath), { recursive: true });
272
- await writeFile(outputPath, result.code);
273
- return { success: true };
300
+ return { success: true, code: result.code };
274
301
  } catch (e) {
275
302
  // If minification fails (e.g., non-standard syntax), fall back to raw concatenation
276
303
  console.warn(`⚠️ JS minification failed, trying unminified bundle: ${e.message}`);
@@ -284,13 +311,60 @@ export async function bundleJs(filePaths, outputPath, { minify = true, minifySyn
284
311
  await esbuild.transform(combined, { loader: "js" });
285
312
  } catch (e) {
286
313
  console.warn(`⚠️ JS bundle has syntax errors, keeping individual script tags: ${e.message}`);
287
- return { success: false };
314
+ return { success: false, code: "" };
288
315
  }
289
316
 
290
- // Write raw concatenated code (syntax-valid but unminified)
291
- await mkdir(dirname(outputPath), { recursive: true });
292
- await writeFile(outputPath, combined);
293
- return { success: true };
317
+ // Raw concatenated code (syntax-valid but unminified)
318
+ return { success: true, code: combined };
319
+ }
320
+
321
+ /**
322
+ * Where a template's `/public/<rel>` reference lives in the meta directory:
323
+ * the template's own folder first, then `meta/shared`, then (legacy) the meta
324
+ * root. Every probe is a recorded lookup, so moving an asset between the two
325
+ * folders is observed. Returns null when nothing exists.
326
+ */
327
+ export function resolveMetaAssetPath(publicPath, templateDir, metaDir) {
328
+ const relativePath = publicPath.replace(/^\/public\//, "");
329
+ for (const candidate of [
330
+ resolve(templateDir, relativePath),
331
+ resolve(metaDir, "shared", relativePath),
332
+ resolve(metaDir, relativePath),
333
+ ]) {
334
+ if (existsSync(candidate)) return candidate;
335
+ }
336
+ return null;
337
+ }
338
+
339
+ /**
340
+ * Append `?v=<hash>` to every `url()` in a stylesheet whose target `lookup`
341
+ * knows. URLs with a query string, data: URIs, fragments and anything the
342
+ * lookup does not recognise are left alone. Cache-busting by content hash
343
+ * keeps an unchanged asset's URL stable, so pages are not rewritten for it.
344
+ * @param {string} css
345
+ * @param {(url: string) => string|null} lookup - site-absolute URL → content hash
346
+ */
347
+ export function versionCssUrls(css, lookup) {
348
+ return css.replace(/url\(\s*(['"]?)(?!data:)([^'"\)]+?)\1\s*\)/gi, (match, quote, url) => {
349
+ if (url.includes("?") || url.startsWith("#")) return match;
350
+ const hash = lookup(url);
351
+ if (!hash) return match;
352
+ return `url(${quote}${url}?v=${hash}${quote})`;
353
+ });
354
+ }
355
+
356
+ /**
357
+ * Rewrite literal `fetch('/public/x.json')` calls in template JavaScript so
358
+ * the request carries the page's build id: `data-build` on <body>, set once
359
+ * per serve session and once per generate run. The bundle itself stays a pure
360
+ * function of the meta files — baking a JSON file's content hash into it
361
+ * would rewrite the bundle, and so every page, whenever the menu changed.
362
+ */
363
+ export function rewriteJsonFetches(js) {
364
+ return js.replace(
365
+ /fetch\((['"])([^'"\)]+\.json)\1(?!\s*\+)/g,
366
+ (m, q, url) => `fetch(${q}${url}?v=${q}+(document.body&&document.body.dataset.build||'')`
367
+ );
294
368
  }
295
369
 
296
370
  /**
@@ -1,9 +1,8 @@
1
- import dirTree from "directory-tree";
2
1
  import { isHiddenOrSystemPath } from "./hiddenPaths.js";
3
2
  import { extname, basename, join, dirname } from "path";
4
- import { existsSync, readFileSync } from "fs";
3
+ import { existsSync, readFileSync, readdirSync, isIgnoredDirEntry } from "./build/tracedFs.js";
5
4
  import { getFolderConfig, isFolderHidden, getRootConfig } from "./folderConfig.js";
6
- import { isMetadataOnly } from "./metadataExtractor.js";
5
+ import { isMenuFile } from "./customMenu.js";
7
6
  import {
8
7
  INDEX_EXTENSIONS,
9
8
  toDisplayName,
@@ -11,6 +10,7 @@ import {
11
10
  getMenuSortAsFromFile,
12
11
  getFolderLabel,
13
12
  getFolderSortKey,
13
+ readFrontmatterInfo,
14
14
  } from "./menuLabels.js";
15
15
 
16
16
  // Icon extensions to check for custom icons
@@ -201,20 +201,15 @@ function buildMenuData(tree, source, validPaths, parentPath = '', includeDebug =
201
201
  const relativePath = item.path.replace(source, '');
202
202
  const folderPath = parentPath ? `${parentPath}/${baseName}` : baseName;
203
203
 
204
- // Skip hidden files (config.json, style.css, etc.)
205
- if (!hasChildren && hiddenFiles.includes(fileName)) {
204
+ // Skip hidden files (config.json, style.css, etc.) and menu files
205
+ if (!hasChildren && (hiddenFiles.includes(fileName) || isMenuFile(fileName))) {
206
206
  continue;
207
207
  }
208
208
 
209
209
  // Skip metadata-only index files (they only provide folder metadata, not actual pages)
210
210
  if (!hasChildren && isIndexFile(baseName)) {
211
- try {
212
- const content = readFileSync(item.path, 'utf8');
213
- if (isMetadataOnly(content)) {
214
- continue; // Skip - this file doesn't produce a page
215
- }
216
- } catch (e) {
217
- // If we can't read it, include it in the menu
211
+ if (readFrontmatterInfo(item.path)?.isMetadataOnly) {
212
+ continue; // Skip - this file doesn't produce a page
218
213
  }
219
214
  }
220
215
 
@@ -390,6 +385,37 @@ export function pruneHiddenNodes(node, source) {
390
385
  };
391
386
  }
392
387
 
388
+ /**
389
+ * Build a directory tree in the shape `directory-tree` produced
390
+ * ({name, path, children?}), reading through the traced filesystem so the
391
+ * build graph records every listing the menu depends on. Entries are sorted
392
+ * by name; readdir order never reaches the menu.
393
+ * @param {string} dir - Absolute directory path
394
+ * @returns {object|null} Tree node, or null if `dir` cannot be listed
395
+ */
396
+ function walkTree(dir) {
397
+ let entries;
398
+ try {
399
+ entries = readdirSync(dir, { withFileTypes: true });
400
+ } catch {
401
+ return null;
402
+ }
403
+ const children = [];
404
+ for (const entry of [...entries].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))) {
405
+ // Never inputs (see tracedFs.isIgnoredDirEntry); skipping them here also
406
+ // keeps the walk out of a .git or node_modules sitting inside the docroot.
407
+ if (isIgnoredDirEntry(entry.name)) continue;
408
+ const path = join(dir, entry.name);
409
+ if (entry.isDirectory()) {
410
+ const child = walkTree(path);
411
+ if (child) children.push(child);
412
+ } else if (entry.isFile()) {
413
+ children.push({ name: entry.name, path });
414
+ }
415
+ }
416
+ return { name: basename(dir), path: dir, children };
417
+ }
418
+
393
419
  export async function getAutomenu(source, validPaths) {
394
420
  /*
395
421
  * Walk first, prune second.
@@ -406,7 +432,7 @@ export async function getAutomenu(source, validPaths) {
406
432
  * discarded; docroots do not normally contain one, and correctness on every
407
433
  * ordinary path is worth more than speed on a pathological one.
408
434
  */
409
- const fullTree = dirTree(source);
435
+ const fullTree = walkTree(source.replace(/\/$/, ''));
410
436
  if (!fullTree) {
411
437
  throw new Error(
412
438
  `Cannot read docroot for menu generation: ${source} (does it exist and is it a directory?)`
@@ -1,9 +1,7 @@
1
1
  import { join } from "path";
2
- import { mkdtemp, mkdir, writeFile, rm, readFile } from "fs/promises";
3
- import { existsSync } from "fs";
2
+ import { mkdtemp, mkdir, writeFile, rm } from "fs/promises";
4
3
  import { tmpdir } from "os";
5
- import { generateAutoIndices, generateAutoIndexHtmlFromSource } from "../autoIndex.js";
6
- import { clearConfigCache } from "../../folderConfig.js";
4
+ import { generateAutoIndexHtmlFromSource } from "../autoIndex.js";
7
5
 
8
6
  let tempDir;
9
7
  let source;
@@ -19,82 +17,6 @@ afterEach(async () => {
19
17
  await rm(tempDir, { recursive: true, force: true });
20
18
  });
21
19
 
22
- const TEMPLATE =
23
- "<html><head>${styleLink}</head><body>${menu}${body}${footer}${customScript}</body></html>";
24
-
25
- function makeProgress() {
26
- const logs = [];
27
- return {
28
- logs,
29
- log: (msg) => logs.push(msg),
30
- status: () => {},
31
- done: () => {},
32
- };
33
- }
34
-
35
- function runAutoIndices(directories, generatedArticles, progress) {
36
- return generateAutoIndices(
37
- output,
38
- directories,
39
- source,
40
- { "default-template": TEMPLATE },
41
- "",
42
- "",
43
- generatedArticles,
44
- new Set(),
45
- new Set(),
46
- "20260101000000",
47
- progress,
48
- null
49
- );
50
- }
51
-
52
- describe("generateAutoIndices with empty source folders", () => {
53
- it("skips output directories that were never created instead of logging an error", async () => {
54
- // Source has an empty folder (guides) and a folder with a document (docs).
55
- // Only docs produced output files, so output/guides does not exist.
56
- await mkdir(join(source, "guides"));
57
- await mkdir(join(source, "docs"));
58
- await writeFile(join(source, "docs", "hello.md"), "# Hello\n\nWorld\n");
59
- await mkdir(join(output, "docs"));
60
- await writeFile(join(output, "docs", "hello.html"), "<html><body>Hello</body></html>");
61
-
62
- const progress = makeProgress();
63
- await runAutoIndices(
64
- [join(source, "guides"), join(source, "docs")],
65
- [join(source, "docs", "hello.md")],
66
- progress
67
- );
68
-
69
- const errors = progress.logs.filter((m) => /Error generating auto-index/i.test(m));
70
- expect(errors).toEqual([]);
71
- // The missing output directory is skipped, not created
72
- expect(existsSync(join(output, "guides"))).toBe(false);
73
- expect(existsSync(join(output, "guides", "index.html"))).toBe(false);
74
- });
75
-
76
- it("still generates auto-indices for folders that produced output", async () => {
77
- await mkdir(join(source, "guides"));
78
- await mkdir(join(source, "docs"));
79
- await writeFile(join(source, "docs", "hello.md"), "# Hello\n\nWorld\n");
80
- await mkdir(join(output, "docs"));
81
- await writeFile(join(output, "docs", "hello.html"), "<html><body>Hello</body></html>");
82
-
83
- const progress = makeProgress();
84
- await runAutoIndices(
85
- [join(source, "guides"), join(source, "docs")],
86
- [join(source, "docs", "hello.md")],
87
- progress
88
- );
89
-
90
- // Root and docs both exist in output, so both get an index.html
91
- const docsIndex = await readFile(join(output, "docs", "index.html"), "utf8");
92
- expect(docsIndex).toContain('<a href="hello.html">');
93
- const rootIndex = await readFile(join(output, "index.html"), "utf8");
94
- expect(rootIndex).toContain('<a href="docs/index.html">');
95
- });
96
- });
97
-
98
20
  describe("auto-index naming matches the automenu", () => {
99
21
  it("uses menu-label from a folder's index frontmatter, and config.json when there is no index", async () => {
100
22
  // bnw has a real index.md carrying the label; hfr has no index at all, so
@@ -140,35 +62,9 @@ describe("auto-index naming matches the automenu", () => {
140
62
 
141
63
  expect(html).toContain('<a href="SoL/index.html">SoL</a>');
142
64
  });
143
-
144
- it("labels generated index pages with the folder's menu-label", async () => {
145
- // No index.md content, so this folder gets an auto-generated index.html;
146
- // its <h1> should use the label rather than the raw folder name.
147
- await mkdir(join(source, "bnw"));
148
- await writeFile(
149
- join(source, "bnw", "config.json"),
150
- JSON.stringify({ label: "BNW - Brave New World" })
151
- );
152
- await writeFile(join(source, "bnw", "quests.md"), "# Quests\n");
153
- await mkdir(join(output, "bnw"));
154
- await writeFile(join(output, "bnw", "quests.html"), "<html><body>Quests</body></html>");
155
-
156
- await runAutoIndices([join(source, "bnw")], [join(source, "bnw", "quests.md")], makeProgress());
157
-
158
- const bnwIndex = await readFile(join(output, "bnw", "index.html"), "utf8");
159
- expect(bnwIndex).toContain("<h1>BNW - Brave New World</h1>");
160
- const rootIndex = await readFile(join(output, "index.html"), "utf8");
161
- expect(rootIndex).toContain('<a href="bnw/index.html">BNW - Brave New World</a>');
162
- });
163
65
  });
164
66
 
165
67
  describe("folders ignored via config.json { hidden: true }", () => {
166
- beforeEach(() => {
167
- // getFolderConfig memoizes per absolute path; temp dirs are unique per
168
- // test, but clearing keeps the cache from growing across the suite.
169
- clearConfigCache();
170
- });
171
-
172
68
  it("omits a hidden folder from an auto-index built from source", async () => {
173
69
  await mkdir(join(source, "_art"), { recursive: true });
174
70
  await writeFile(join(source, "_art", "config.json"), JSON.stringify({ hidden: true }));
@@ -183,32 +79,6 @@ describe("folders ignored via config.json { hidden: true }", () => {
183
79
  expect(html).not.toContain("prompts");
184
80
  });
185
81
 
186
- it("omits a hidden folder even when stale output for it still exists", async () => {
187
- // A folder generated before it was hidden leaves files behind in output.
188
- // The listing is built from output, so without a source-side check the
189
- // hidden folder would reappear in the index.
190
- await mkdir(join(source, "_art"), { recursive: true });
191
- await writeFile(join(source, "_art", "config.json"), JSON.stringify({ hidden: true }));
192
- await mkdir(join(source, "people"), { recursive: true });
193
- await writeFile(join(source, "people", "alice.md"), "# Alice\n");
194
-
195
- await mkdir(join(output, "_art"), { recursive: true });
196
- await writeFile(join(output, "_art", "prompts.html"), "<html><body>stale</body></html>");
197
- await mkdir(join(output, "people"), { recursive: true });
198
- await writeFile(join(output, "people", "alice.html"), "<html><body>Alice</body></html>");
199
-
200
- const progress = makeProgress();
201
- await runAutoIndices(
202
- [source, join(source, "people")],
203
- [join(source, "people", "alice.md")],
204
- progress
205
- );
206
-
207
- const index = await readFile(join(output, "index.html"), "utf8");
208
- expect(index).toContain("people");
209
- expect(index).not.toContain("_art");
210
- });
211
-
212
82
  it("does not count documents inside a hidden subfolder when deciding a folder has content", async () => {
213
83
  // `notes` holds nothing but a hidden subfolder, so it produces no pages
214
84
  // and must not be linked as though it did.