@kenjura/ursa 0.93.0 → 0.96.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.
@@ -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
+ });
@@ -218,8 +218,10 @@ function buildMenuData(tree, source, validPaths, parentPath = '', includeDebug =
218
218
  }
219
219
  }
220
220
 
221
- // Check if this folder is hidden via config.json
222
- if (hasChildren && isFolderHidden(item.path, source)) {
221
+ // Check if this folder is hidden via config.json.
222
+ // Not gated on hasChildren: a hidden folder is ignored whether or not the
223
+ // tree walker found children under it.
224
+ if (isFolderHidden(item.path, source)) {
223
225
  continue; // Skip hidden folders
224
226
  }
225
227
 
@@ -3,6 +3,7 @@ import { mkdtemp, mkdir, writeFile, rm, readFile } from "fs/promises";
3
3
  import { existsSync } from "fs";
4
4
  import { tmpdir } from "os";
5
5
  import { generateAutoIndices, generateAutoIndexHtmlFromSource } from "../autoIndex.js";
6
+ import { clearConfigCache } from "../../folderConfig.js";
6
7
 
7
8
  let tempDir;
8
9
  let source;
@@ -160,3 +161,69 @@ describe("auto-index naming matches the automenu", () => {
160
161
  expect(rootIndex).toContain('<a href="bnw/index.html">BNW - Brave New World</a>');
161
162
  });
162
163
  });
164
+
165
+ 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
+ it("omits a hidden folder from an auto-index built from source", async () => {
173
+ await mkdir(join(source, "_art"), { recursive: true });
174
+ await writeFile(join(source, "_art", "config.json"), JSON.stringify({ hidden: true }));
175
+ await writeFile(join(source, "_art", "prompts.md"), "# Prompts\n");
176
+ await mkdir(join(source, "people"), { recursive: true });
177
+ await writeFile(join(source, "people", "alice.md"), "# Alice\n");
178
+
179
+ const html = await generateAutoIndexHtmlFromSource(source, 2);
180
+
181
+ expect(html).toContain("people");
182
+ expect(html).not.toContain("_art");
183
+ expect(html).not.toContain("prompts");
184
+ });
185
+
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
+ it("does not count documents inside a hidden subfolder when deciding a folder has content", async () => {
213
+ // `notes` holds nothing but a hidden subfolder, so it produces no pages
214
+ // and must not be linked as though it did.
215
+ await mkdir(join(source, "notes", "_art"), { recursive: true });
216
+ await writeFile(
217
+ join(source, "notes", "_art", "config.json"),
218
+ JSON.stringify({ hidden: true })
219
+ );
220
+ await writeFile(join(source, "notes", "_art", "prompts.md"), "# Prompts\n");
221
+ await mkdir(join(source, "people"), { recursive: true });
222
+ await writeFile(join(source, "people", "alice.md"), "# Alice\n");
223
+
224
+ const html = await generateAutoIndexHtmlFromSource(source, 2);
225
+
226
+ expect(html).toContain("people");
227
+ expect(html).not.toContain("notes");
228
+ });
229
+ });
@@ -8,7 +8,7 @@ import { findAllScriptJs } from "../findScriptJs.js";
8
8
  import { addTimestampToHtmlStaticRefs } from "./cacheBust.js";
9
9
  import { isMetadataOnly, extractMetadata, getAutoIndexConfig } from "../metadataExtractor.js";
10
10
  import { getCustomMenuForFile } from "./menu.js";
11
- import { getFolderConfig } from "../folderConfig.js";
11
+ import { getFolderConfig, isFolderSelfHidden } from "../folderConfig.js";
12
12
  import {
13
13
  toDisplayName,
14
14
  getFolderLabel,
@@ -26,11 +26,18 @@ const OUTPUT_DOC_EXTENSIONS = ['.html'];
26
26
 
27
27
  /**
28
28
  * Recursively check if a directory contains any document files.
29
+ *
30
+ * Documents inside a config-hidden subfolder do not count: they produce no
31
+ * output, so a folder whose only contents are hidden must not be linked as if
32
+ * it had pages. When `dir` is an output directory, pass the matching source
33
+ * directory as `sourceDir` — the config.json lives in the source tree.
34
+ *
29
35
  * @param {string} dir - Directory path to check
30
36
  * @param {string[]} extensions - File extensions that count as documents
37
+ * @param {string|null} [sourceDir=dir] - Matching source directory, for hidden lookups
31
38
  * @returns {Promise<boolean>} True if the directory (or any subdirectory) contains at least one document
32
39
  */
33
- async function directoryHasDocuments(dir, extensions) {
40
+ async function directoryHasDocuments(dir, extensions, sourceDir = dir) {
34
41
  try {
35
42
  const children = await readdir(dir, { withFileTypes: true });
36
43
  for (const child of children) {
@@ -38,7 +45,9 @@ async function directoryHasDocuments(dir, extensions) {
38
45
  const fullPath = join(dir, child.name);
39
46
  if (child.isDirectory()) {
40
47
  if (child.name === 'img') continue;
41
- if (await directoryHasDocuments(fullPath, extensions)) return true;
48
+ if (sourceDir && isFolderSelfHidden(join(sourceDir, child.name))) continue;
49
+ const childSource = sourceDir ? join(sourceDir, child.name) : null;
50
+ if (await directoryHasDocuments(fullPath, extensions, childSource)) return true;
42
51
  } else {
43
52
  const ext = extname(child.name).toLowerCase();
44
53
  if (extensions.includes(ext)) return true;
@@ -111,6 +120,9 @@ export async function generateAutoIndexHtml(dir, depth = 1, currentDepth = 0, pa
111
120
  if (child.name === 'index.html') return false;
112
121
  // Skip img folders (contain images, not content)
113
122
  if (child.isDirectory() && child.name === 'img') return false;
123
+ // Skip folders config.json marks hidden — they are ignored entirely,
124
+ // so a stale output directory must not resurrect them in a listing
125
+ if (child.isDirectory() && sourceDir && isFolderSelfHidden(join(sourceDir, child.name))) return false;
114
126
  // Include directories and html files
115
127
  return child.isDirectory() || child.name.endsWith('.html');
116
128
  })
@@ -131,7 +143,8 @@ export async function generateAutoIndexHtml(dir, depth = 1, currentDepth = 0, pa
131
143
  // Skip directories that contain no documents
132
144
  if (isDir) {
133
145
  const childDir = join(dir, child.name);
134
- if (!await directoryHasDocuments(childDir, OUTPUT_DOC_EXTENSIONS)) continue;
146
+ const childSource = sourceDir ? join(sourceDir, child.name) : null;
147
+ if (!await directoryHasDocuments(childDir, OUTPUT_DOC_EXTENSIONS, childSource)) continue;
135
148
  }
136
149
  // Use pathPrefix to ensure hrefs are correct relative to the document root
137
150
  const childPath = pathPrefix ? `${pathPrefix}/${child.name}` : child.name;
@@ -186,6 +199,8 @@ export async function generateAutoIndexHtmlFromSource(sourceDir, depth = 1, curr
186
199
  if (child.name.match(/^index\.(md|mdx|txt|yml|html)$/i)) return false;
187
200
  // Skip img folders (contain images, not content)
188
201
  if (child.isDirectory() && child.name === 'img') return false;
202
+ // Skip folders config.json marks hidden — they produce no output
203
+ if (child.isDirectory() && isFolderSelfHidden(join(sourceDir, child.name))) return false;
189
204
  // Include directories and article files (md, mdx, txt, yml, html)
190
205
  return child.isDirectory() || child.name.match(/\.(md|mdx|txt|yml|html)$/i);
191
206
  })
@@ -362,6 +377,8 @@ export async function generateAutoIndices(output, directories, source, templates
362
377
  // Skip hidden files and index alternates we just checked
363
378
  if (child.name.startsWith('.')) return false;
364
379
  if (child.name === 'index.html') return false;
380
+ // Skip folders config.json marks hidden — they produce no output
381
+ if (child.isDirectory() && isFolderSelfHidden(join(sourceDir, child.name))) return false;
365
382
  // Include directories and html files
366
383
  return child.isDirectory() || child.name.endsWith('.html');
367
384
  })
@@ -377,7 +394,7 @@ export async function generateAutoIndices(output, directories, source, templates
377
394
  for (const { child, isDir, label } of filteredItems) {
378
395
  if (isDir) {
379
396
  const childDir = join(dir, child.name);
380
- if (!await directoryHasDocuments(childDir, OUTPUT_DOC_EXTENSIONS)) continue;
397
+ if (!await directoryHasDocuments(childDir, OUTPUT_DOC_EXTENSIONS, join(sourceDir, child.name))) continue;
381
398
  }
382
399
  // For directories, link to /folder/index.html; for files, use the filename directly
383
400
  const href = isDir ? `${child.name}/index.html` : child.name;
@@ -11,9 +11,16 @@ const configCache = new Map();
11
11
  * {
12
12
  * label?: string, // Custom label for menu display
13
13
  * icon?: string, // URL to icon image for menu
14
- * hidden?: boolean, // If true, hide from menu and don't generate files
14
+ * hidden?: boolean, // If true, ignore the folder entirely (see below)
15
15
  * openMenuItems?: string[] // (root only) Array of folder names to expand by default
16
16
  * }
17
+ *
18
+ * `hidden: true` means *ignored*, not merely unlisted. The folder and its
19
+ * whole subtree take no part in the build: no HTML is rendered from its
20
+ * documents, its images and other static assets are not copied, it does not
21
+ * appear in the sidebar menu, in any auto-index, in breadcrumbs, or in the
22
+ * search index, and `ursa serve` will not render its pages on demand. The
23
+ * files stay in the docroot; the site behaves as if they were not there.
17
24
  */
18
25
 
19
26
  /**
@@ -60,10 +67,33 @@ export function getRootConfig(sourceRoot) {
60
67
  }
61
68
 
62
69
  /**
63
- * Check if a folder or any of its ancestors is hidden via config.json
64
- * @param {string} folderPath - Absolute path to check
70
+ * True when this exact folder's own config.json says `hidden: true`, ignoring
71
+ * its ancestors.
72
+ *
73
+ * Use this where the ancestors have already been ruled out — walking a tree
74
+ * top-down, say, where reaching a node means every folder above it was
75
+ * visible. It needs no docroot, which is what makes it usable in the
76
+ * auto-index builders, where only the folder being listed is known.
77
+ *
78
+ * @param {string} folderPath - Absolute path to a folder
79
+ * @returns {boolean} True if that folder is marked hidden
80
+ */
81
+ export function isFolderSelfHidden(folderPath) {
82
+ return getFolderConfig(folderPath.replace(/\/$/, ''))?.hidden === true;
83
+ }
84
+
85
+ /**
86
+ * Check if a path lies in a folder — its own, or any ancestor up to the
87
+ * docroot — that config.json marks `hidden: true`.
88
+ *
89
+ * Accepts file paths as well as directories: the walk starts at `folderPath`
90
+ * itself, and a file simply has no config.json of its own, so the first step
91
+ * misses and the ancestors decide. That is what lets the build filter a mixed
92
+ * list of files and directories through one predicate.
93
+ *
94
+ * @param {string} folderPath - Absolute path to check (file or directory)
65
95
  * @param {string} sourceRoot - The source root directory (stop checking at this level)
66
- * @returns {boolean} True if this folder should be hidden
96
+ * @returns {boolean} True if this path should be ignored
67
97
  */
68
98
  export function isFolderHidden(folderPath, sourceRoot) {
69
99
  let currentPath = folderPath.replace(/\/$/, '');