@kenjura/ursa 0.95.0 → 0.97.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 (52) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/README.md +114 -16
  3. package/bin/ursa.js +14 -1
  4. package/meta/templates/default-template/content-hooks.js +45 -0
  5. package/meta/templates/default-template/index.html +1 -0
  6. package/meta/templates/default-template/menu.js +18 -1
  7. package/meta/templates/default-template/search.js +11 -0
  8. package/meta/templates/default-template/sticky.js +7 -1
  9. package/meta/templates/default-template/toc-generator.js +58 -38
  10. package/meta/templates/default-template/widgets.js +4 -0
  11. package/package.json +1 -2
  12. package/src/dev.js +13 -23
  13. package/src/helper/__test__/contentHash.test.js +16 -6
  14. package/src/helper/__test__/mdxRenderer.test.js +159 -0
  15. package/src/helper/__test__/sourceTimestamps.test.js +0 -0
  16. package/src/helper/assetBundler.js +93 -19
  17. package/src/helper/automenu.js +36 -11
  18. package/src/helper/build/__test__/autoIndex.test.js +2 -132
  19. package/src/helper/build/__test__/graph.test.js +259 -3
  20. package/src/helper/build/__test__/pass.test.js +553 -0
  21. package/src/helper/build/autoIndex.js +2 -371
  22. package/src/helper/build/excludeFilter.js +1 -2
  23. package/src/helper/build/footer.js +27 -14
  24. package/src/helper/build/graph.js +575 -152
  25. package/src/helper/build/index.js +0 -2
  26. package/src/helper/build/metadata.js +19 -5
  27. package/src/helper/build/pass.js +497 -0
  28. package/src/helper/build/precedence.js +174 -0
  29. package/src/helper/build/site.js +1270 -0
  30. package/src/helper/build/templates.js +1 -2
  31. package/src/helper/build/tracedFs.js +247 -0
  32. package/src/helper/contentHash.js +0 -78
  33. package/src/helper/customMenu.js +1 -1
  34. package/src/helper/fileRenderer.js +119 -111
  35. package/src/helper/findScriptJs.js +1 -1
  36. package/src/helper/findStyleCss.js +1 -1
  37. package/src/helper/folderConfig.js +7 -18
  38. package/src/helper/fullTextIndex.js +41 -29
  39. package/src/helper/imageProcessor.js +45 -0
  40. package/src/helper/linkValidator.js +118 -127
  41. package/src/helper/mdxRenderer.js +225 -26
  42. package/src/helper/menuLabels.js +30 -5
  43. package/src/helper/sourceTimestamps.js +139 -0
  44. package/src/helper/ursaConfig.js +3 -49
  45. package/src/helper/whitelistFilter.js +1 -2
  46. package/src/jobs/generate.js +67 -1859
  47. package/src/serve.js +317 -697
  48. package/src/helper/__test__/dependencyTracker.test.js +0 -157
  49. package/src/helper/build/cacheBust.js +0 -141
  50. package/src/helper/build/navCache.js +0 -145
  51. package/src/helper/build/watchCache.js +0 -33
  52. package/src/helper/dependencyTracker.js +0 -384
@@ -0,0 +1,159 @@
1
+ import { join } from 'path';
2
+ import { mkdtemp, writeFile, mkdir, rm } from 'fs/promises';
3
+ import { tmpdir } from 'os';
4
+ import { renderMDX, generateHydrationScript } from '../mdxRenderer.js';
5
+
6
+ // Helper: a temp source tree with a _components/ directory
7
+ let tempDir;
8
+ beforeEach(async () => {
9
+ tempDir = await mkdtemp(join(tmpdir(), 'ursa-mdx-'));
10
+ await mkdir(join(tempDir, '_components'), { recursive: true });
11
+ });
12
+ afterEach(async () => {
13
+ await rm(tempDir, { recursive: true, force: true });
14
+ });
15
+
16
+ async function writeComponent(name, body) {
17
+ const path = join(tempDir, '_components', `${name}.jsx`);
18
+ await writeFile(path, body);
19
+ return path;
20
+ }
21
+
22
+ async function render(mdx, { hydrate = true } = {}) {
23
+ const filePath = join(tempDir, 'page.mdx');
24
+ await writeFile(filePath, mdx);
25
+ return renderMDX({ source: mdx, filePath, sourceRoot: tempDir, hydrate });
26
+ }
27
+
28
+ const COUNTER = `
29
+ import React from 'react';
30
+ export default function Counter({ label }) {
31
+ const [n, setN] = React.useState(0);
32
+ return <button onClick={() => setN(n + 1)}>{label}: {n}</button>;
33
+ }
34
+ `;
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // Island wrapping
38
+ // ---------------------------------------------------------------------------
39
+ describe('renderMDX islands', () => {
40
+ test('wraps a component imported from the MDX in <ursa-island>', async () => {
41
+ await writeComponent('Counter', COUNTER);
42
+ const { html } = await render(`
43
+ import Counter from '_components/Counter.jsx';
44
+
45
+ # Page
46
+
47
+ <Counter label="Clicks" />
48
+ `);
49
+ expect(html).toMatch(/<ursa-island data-island="0" data-component="Counter"[^>]*>/);
50
+ expect(html).toContain('<button>Clicks<!-- -->: <!-- -->0</button>');
51
+ expect(html).toContain('</ursa-island>');
52
+ });
53
+
54
+ test('numbers islands in document order', async () => {
55
+ await writeComponent('Counter', COUNTER);
56
+ const { html } = await render(`
57
+ import Counter from '_components/Counter.jsx';
58
+
59
+ <Counter label="A" />
60
+
61
+ Some prose.
62
+
63
+ <Counter label="B" />
64
+ `);
65
+ const ids = [...html.matchAll(/<ursa-island data-island="(\d+)"/g)].map((m) => m[1]);
66
+ expect(ids).toEqual(['0', '1']);
67
+ expect(html.indexOf('>A<')).toBeLessThan(html.indexOf('>B<'));
68
+ });
69
+
70
+ test('does not nest islands: a component imported by a component is rendered plainly', async () => {
71
+ await writeComponent('Inner', `
72
+ import React from 'react';
73
+ export default function Inner() { return <em>inner</em>; }
74
+ `);
75
+ await writeComponent('Outer', `
76
+ import React from 'react';
77
+ import Inner from './Inner.jsx';
78
+ export default function Outer() { return <div>outer <Inner /></div>; }
79
+ `);
80
+ const { html } = await render(`
81
+ import Outer from '_components/Outer.jsx';
82
+
83
+ <Outer />
84
+ `);
85
+ expect(html.match(/<ursa-island/g)).toHaveLength(1);
86
+ expect(html).toContain('data-component="Outer"');
87
+ expect(html).toContain('<em>inner</em>');
88
+ });
89
+
90
+ test('leaves the surrounding markdown outside any island', async () => {
91
+ await writeComponent('Counter', COUNTER);
92
+ const { html } = await render(`
93
+ import Counter from '_components/Counter.jsx';
94
+
95
+ # Heading
96
+
97
+ <Counter label="x" />
98
+
99
+ ## Sub
100
+ `);
101
+ // Headings are siblings of the island, not children of it
102
+ expect(html).toMatch(/<h1>Heading<\/h1>\s*<ursa-island/);
103
+ expect(html).toMatch(/<\/ursa-island>\s*<h2>Sub<\/h2>/);
104
+ });
105
+
106
+ test('client bundle contains the island runtime', async () => {
107
+ await writeComponent('Counter', COUNTER);
108
+ const { clientCode } = await render(`
109
+ import Counter from '_components/Counter.jsx';
110
+
111
+ <Counter label="x" />
112
+ `);
113
+ expect(clientCode).toContain('hydrateRoot');
114
+ expect(clientCode).toContain('data-island');
115
+ });
116
+
117
+ test('hydrate: false still renders islands but emits no client code', async () => {
118
+ await writeComponent('Counter', COUNTER);
119
+ const result = await render(`
120
+ import Counter from '_components/Counter.jsx';
121
+
122
+ <Counter label="x" />
123
+ `, { hydrate: false });
124
+ expect(result.html).toContain('<ursa-island');
125
+ expect(result.clientCode).toBeUndefined();
126
+ });
127
+ });
128
+
129
+ // ---------------------------------------------------------------------------
130
+ // Preload stripping
131
+ // ---------------------------------------------------------------------------
132
+ describe('renderMDX preload stripping', () => {
133
+ test('removes React 19 hoisted <link rel="preload"> so the body starts with its heading', async () => {
134
+ const { html } = await render(`
135
+ # Title
136
+ ![pic](../img/pic.jpg)
137
+ `);
138
+ expect(html).not.toContain('rel="preload"');
139
+ expect(html.trimStart().startsWith('<h1>')).toBe(true);
140
+ });
141
+ });
142
+
143
+ // ---------------------------------------------------------------------------
144
+ // Hydration script
145
+ // ---------------------------------------------------------------------------
146
+ describe('generateHydrationScript', () => {
147
+ test('renders into a detached root and looks up islands rather than a page container', () => {
148
+ const script = generateHydrationScript('return { default: function () { return null; } };');
149
+ expect(script).toContain('ursa-island[data-island]');
150
+ expect(script).toContain('createRoot(detached)');
151
+ expect(script).not.toContain("getElementById('main-content')");
152
+ });
153
+
154
+ test('escapes the bundle for embedding in a template literal', () => {
155
+ const script = generateHydrationScript('var s = `a${b}`; // </script>');
156
+ expect(script).toContain('\\`a\\${b}\\`');
157
+ expect(script).toContain('<\\/script>');
158
+ });
159
+ });
@@ -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,7 @@
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";
7
5
  import {
8
6
  INDEX_EXTENSIONS,
9
7
  toDisplayName,
@@ -11,6 +9,7 @@ import {
11
9
  getMenuSortAsFromFile,
12
10
  getFolderLabel,
13
11
  getFolderSortKey,
12
+ readFrontmatterInfo,
14
13
  } from "./menuLabels.js";
15
14
 
16
15
  // Icon extensions to check for custom icons
@@ -208,13 +207,8 @@ function buildMenuData(tree, source, validPaths, parentPath = '', includeDebug =
208
207
 
209
208
  // Skip metadata-only index files (they only provide folder metadata, not actual pages)
210
209
  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
210
+ if (readFrontmatterInfo(item.path)?.isMetadataOnly) {
211
+ continue; // Skip - this file doesn't produce a page
218
212
  }
219
213
  }
220
214
 
@@ -390,6 +384,37 @@ export function pruneHiddenNodes(node, source) {
390
384
  };
391
385
  }
392
386
 
387
+ /**
388
+ * Build a directory tree in the shape `directory-tree` produced
389
+ * ({name, path, children?}), reading through the traced filesystem so the
390
+ * build graph records every listing the menu depends on. Entries are sorted
391
+ * by name; readdir order never reaches the menu.
392
+ * @param {string} dir - Absolute directory path
393
+ * @returns {object|null} Tree node, or null if `dir` cannot be listed
394
+ */
395
+ function walkTree(dir) {
396
+ let entries;
397
+ try {
398
+ entries = readdirSync(dir, { withFileTypes: true });
399
+ } catch {
400
+ return null;
401
+ }
402
+ const children = [];
403
+ for (const entry of [...entries].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))) {
404
+ // Never inputs (see tracedFs.isIgnoredDirEntry); skipping them here also
405
+ // keeps the walk out of a .git or node_modules sitting inside the docroot.
406
+ if (isIgnoredDirEntry(entry.name)) continue;
407
+ const path = join(dir, entry.name);
408
+ if (entry.isDirectory()) {
409
+ const child = walkTree(path);
410
+ if (child) children.push(child);
411
+ } else if (entry.isFile()) {
412
+ children.push({ name: entry.name, path });
413
+ }
414
+ }
415
+ return { name: basename(dir), path: dir, children };
416
+ }
417
+
393
418
  export async function getAutomenu(source, validPaths) {
394
419
  /*
395
420
  * Walk first, prune second.
@@ -406,7 +431,7 @@ export async function getAutomenu(source, validPaths) {
406
431
  * discarded; docroots do not normally contain one, and correctness on every
407
432
  * ordinary path is worth more than speed on a pathological one.
408
433
  */
409
- const fullTree = dirTree(source);
434
+ const fullTree = walkTree(source.replace(/\/$/, ''));
410
435
  if (!fullTree) {
411
436
  throw new Error(
412
437
  `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.