@kenjura/ursa 0.93.0 → 0.95.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,35 @@
1
+ # 0.95.0
2
+ 2026-09-06
3
+
4
+ `generate --json-only` builds the data and skips the site.
5
+
6
+ ursa emits a `.json` beside every document and a `<dir>.json` record list beside every directory, and those files are useful on their own — an application can ingest a docs repo at build time and never serve the HTML. Getting them, though, meant paying for the whole site: rendering every page, resizing every image, bundling the React runtime, and writing a full-text index nobody would query. On the system8 docs that is 2.85s and 88 MB to obtain 8.8 MB of JSON.
7
+
8
+ - **`--json-only` (`-j`) emits only the data files.** Skipped: HTML, XML, images and their previews, meta/template assets, the React runtime, per-folder CSS/JS bundles, static file copying (fonts, audio, video, PDFs), the search and full-text indices, `menu-data.json`, `recent-activity.json`, and auto-generated index pages. On the system8 docs: **1.12s and 8.8 MB**, down from 2.85s and 88 MB.
9
+ - **The JSON is byte-identical to a full build's**, not merely similar — verified across all 1,475 content files of the system8 docs, and asserted in the test suite. Every step the mode skips operates on the assembled *page*: `bodyHtml` in the JSON is the pre-template render, and `transformImageTags`, `markInactiveLinks` and `resolveRelativeUrls` only ever rewrote the finished HTML. So there is nothing for image processing or template bundling to contribute to it.
10
+ - **The directory record lists are kept.** They are the one thing a data consumer most wants — `character/powers.json` is the list of every power with its frontmatter — so only the directory's *listing page* is skipped, never its `.json`.
11
+
12
+ Mixing modes against one source tree is safe. The `.ursa` hash cache lives in the source and is shared, so the per-document output check now asks only for the outputs the current mode emits: a JSON-only run after a full build skips work (the `.json` is present and identical either way), and a full build after a JSON-only run regenerates, because its `.html` and `.xml` are missing. That check already existed for a related reason — one hash cache serving several output directories — and it extends to modes for free.
13
+
14
+ Two pieces of build state are deliberately not written by a JSON-only run. The dependency graph is skipped because registration lives inside the page assembly the mode skips, so saving would replace a full build's graph with an empty one. The watch-mode cache is skipped because it would be seeded with unbundled templates and an empty image map, which would make a later single-file regeneration emit an unstyled page.
15
+
16
+ # 0.94.0
17
+ 2026-09-04
18
+
19
+ `config.json { hidden: true }` now actually ignores a folder.
20
+
21
+ It was documented as "hide from menu and don't generate files", and it did neither reliably. `generate` filtered articles and directories, but every other category was derived from an unfiltered list, so a hidden folder still had its images, fonts, audio and video copied into the output and its hand-written HTML carried across. Auto-index listings never consulted the setting at all, so a hidden folder was listed — with working-looking links — in its parent's index. `serve` rendered its pages on request, which is the worst version of the bug: the page works all through development and 404s in production, exactly the failure the setting exists to prevent.
22
+
23
+ - **One filter, applied once.** `generate` now drops hidden paths from the whole source file list, ahead of classification, instead of re-checking in each category. Articles, directories, images, media and hand-written HTML all inherit it, so no category can be missed — which is how images and media came to be copied out of hidden folders in the first place.
24
+ - **Auto-indices skip hidden folders**, in all three listing paths: from source, from output, and the fallback index generated for a folder without one. The output-scanning path checks the *source* tree, so a folder hidden after it was generated does not reappear from stale files left in `output/`.
25
+ - **A folder holding nothing but a hidden subfolder is no longer treated as having content**, and so is not linked as if it had pages.
26
+ - **`serve` returns 404 for anything under a hidden folder** — documents, images, `config.json` itself — from a single gate ahead of the static-file fallbacks. `serve` and `generate` now agree.
27
+ - **The menu check no longer depends on the folder having children**, so an empty hidden folder is skipped too.
28
+
29
+ `isFolderHidden()` accepts file paths as well as directories: the walk begins at the path itself, and a file has no `config.json` of its own, so its ancestors decide. That is what lets one predicate filter a mixed list of files and directories. Its shallow companion `isFolderSelfHidden()` tests one folder without a docroot, for the auto-index builders, which know only the folder they are listing.
30
+
31
+ Files already written to `output/` before a folder was hidden are still not deleted — nothing links to them any more, but removing them needs `--clean`. That is the general "generate never deletes" behaviour, unchanged here.
32
+
1
33
  # 0.93.0
2
34
  2026-09-03
3
35
 
package/README.md CHANGED
@@ -67,6 +67,35 @@ Start a development server that:
67
67
  - `--whitelist, -w` - Path to whitelist file containing patterns for files to include
68
68
  - `--exclude, -e` - Folders to exclude: comma-separated paths relative to source, or path to file with one folder per line
69
69
  - `--clean` - Delete the `.ursa` cache folder and clear output directory, forcing full regeneration
70
+ - `--json-only, -j` - Emit only the `.json` data files (generate command only)
71
+
72
+ ### JSON-Only Builds
73
+
74
+ `--json-only` builds the data and skips the site:
75
+
76
+ ```bash
77
+ ursa content --json-only --output data
78
+ ```
79
+
80
+ What it emits: every document's `<name>.json` and every directory's `<dir>.json`
81
+ record list — the same files a normal build writes, byte for byte.
82
+
83
+ What it skips: HTML, XML, images and their previews, meta/template assets, the
84
+ React runtime, per-folder CSS/JS bundles, static file copying (fonts, audio,
85
+ video, PDFs), the search and full-text indices, `menu-data.json`,
86
+ `recent-activity.json`, and auto-generated index pages.
87
+
88
+ This is for pipelines that consume ursa's JSON as data rather than publishing a
89
+ site — ingesting a docs repo into an application at build time, for example.
90
+ Everything skipped operates on the assembled *page*; the JSON's `bodyHtml` is
91
+ the pre-template render, which none of those steps touch. That is why the output
92
+ is identical rather than merely similar.
93
+
94
+ Mixing modes against one source tree is safe. The two share the `.ursa` hash
95
+ cache, but each asks only for the outputs its own mode emits, so a full build
96
+ following a JSON-only build still writes the HTML it is missing. A JSON-only
97
+ build does not write the dependency graph or seed the watch cache, so it cannot
98
+ degrade a later `serve`.
70
99
 
71
100
  ### Whitelist File Format
72
101
 
@@ -124,6 +153,44 @@ old-content/v1
124
153
  test/fixtures
125
154
  ```
126
155
 
156
+ ### Ignoring a Folder
157
+
158
+ To keep a folder out of the build permanently — working notes, prompt
159
+ scratchpads, raw source material — put a `config.json` in it:
160
+
161
+ ```json
162
+ {
163
+ "hidden": true
164
+ }
165
+ ```
166
+
167
+ The folder and everything beneath it then take no part in the build:
168
+
169
+ - no HTML is rendered from its documents
170
+ - its images, fonts and other static assets are not copied to the output
171
+ - it does not appear in the sidebar menu, in any auto-index, or in breadcrumbs
172
+ - its text is not added to the search index
173
+ - `ursa serve` returns 404 for anything under it, matching what `generate`
174
+ produces
175
+
176
+ The files stay where they are in the source tree; the site simply behaves as
177
+ though they were not there.
178
+
179
+ This differs from `--exclude` in scope and in lifetime: `--exclude` is a flag on
180
+ one invocation, useful for a one-off or a per-environment build, while
181
+ `config.json` travels with the content and applies to every build and every
182
+ person who checks the repo out.
183
+
184
+ `config.json` accepts a few other keys, all of which apply to the folder it
185
+ sits in:
186
+
187
+ | Key | Type | Meaning |
188
+ | --- | --- | --- |
189
+ | `hidden` | boolean | Ignore this folder and its subtree entirely (above) |
190
+ | `label` | string | Name to show for this folder in menus and indices |
191
+ | `icon` | string | URL of an icon to show beside it in the menu |
192
+ | `openMenuItems` | string[] | Root `config.json` only: folders to expand by default |
193
+
127
194
  ### Large Workloads
128
195
 
129
196
  For sites with many documents (hundreds or thousands), you may need to increase Node.js memory limits:
package/bin/ursa.js CHANGED
@@ -54,6 +54,12 @@ yargs(hideBin(process.argv))
54
54
  .option('promote-changelog', {
55
55
  describe: 'Path to a markdown file to render at the output root (sibling of index.html)',
56
56
  type: 'string'
57
+ })
58
+ .option('json-only', {
59
+ alias: 'j',
60
+ describe: 'Emit only the .json data files — no HTML, XML, images, static assets, search indices or menu data',
61
+ type: 'boolean',
62
+ default: false
57
63
  });
58
64
  },
59
65
  async (argv) => {
@@ -64,7 +70,8 @@ yargs(hideBin(process.argv))
64
70
  const exclude = argv.exclude || null;
65
71
  const clean = argv.clean;
66
72
  const promoteChangelog = argv['promote-changelog'] || null;
67
-
73
+ const jsonOnly = argv['json-only'];
74
+
68
75
  console.log(`Generating site from ${source} to ${output} using meta from ${meta}`);
69
76
  if (whitelist) {
70
77
  console.log(`Using whitelist: ${whitelist}`);
@@ -75,7 +82,10 @@ yargs(hideBin(process.argv))
75
82
  if (clean) {
76
83
  console.log(`Clean build: ignoring cached hashes`);
77
84
  }
78
-
85
+ if (jsonOnly) {
86
+ console.log(`JSON-only build: emitting .json data files only`);
87
+ }
88
+
79
89
  let promoted = { stagedFile: null, cleanup: async () => {} };
80
90
  try {
81
91
  promoted = await stagePromotedChangelog({ changelogPath: promoteChangelog, sourceDir: source });
@@ -85,7 +95,8 @@ yargs(hideBin(process.argv))
85
95
  _output: output,
86
96
  _whitelist: whitelist,
87
97
  _exclude: exclude,
88
- _clean: clean
98
+ _clean: clean,
99
+ _jsonOnly: jsonOnly
89
100
  });
90
101
  console.log('Site generation completed successfully!');
91
102
  } catch (error) {
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.93.0",
5
+ "version": "0.95.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
@@ -372,6 +372,15 @@ async function renderDocument(urlPath) {
372
372
  }
373
373
 
374
374
  const { sourcePath, type } = resolved;
375
+
376
+ // `hidden: true` means the folder takes no part in the build. `generate`
377
+ // writes nothing for it, so serving it on demand would make `serve` and
378
+ // `generate` disagree — the page works all through development and 404s in
379
+ // production, the exact failure mode this is meant to prevent.
380
+ if (isFolderHidden(dirname(sourcePath), source)) {
381
+ return null;
382
+ }
383
+
375
384
  const ext = type;
376
385
  const base = basename(sourcePath, ext);
377
386
  const dir = addTrailingSlash(dirname(sourcePath)).replace(source, "");
@@ -834,6 +843,26 @@ export async function dev({
834
843
  app.use(async (req, res, next) => {
835
844
  const url = req.url;
836
845
 
846
+ // Nothing under a folder that config.json marks `hidden: true` is served.
847
+ //
848
+ // This gate sits ahead of everything, including the `express.static`
849
+ // fallbacks mounted below — falling through with `next()` would just hand
850
+ // the file to them. `generate` writes no output for a hidden folder, so
851
+ // anything served here would work all through development and 404 in
852
+ // production, which is precisely what the setting exists to avoid.
853
+ let requestedSourcePath = null;
854
+ try {
855
+ requestedSourcePath = join(sourceDir, decodeURIComponent(url.split('?')[0]));
856
+ } catch (e) {
857
+ // Malformed percent-encoding — not a path we can classify; let it fall through
858
+ }
859
+ // Tested against the path itself, not its parent: `/foo/_art/` and
860
+ // `/foo/_art/index.html` must both be refused, and a file simply has no
861
+ // config.json of its own, so the ancestors decide either way.
862
+ if (requestedSourcePath && isFolderHidden(requestedSourcePath, sourceDir)) {
863
+ return res.status(404).send('<h1>404 Not Found</h1>');
864
+ }
865
+
837
866
  // Handle search index requests
838
867
  if (url === '/public/search-index.json' || url === '/public/fulltext-index.json') {
839
868
  if (!devState.searchReady) {
@@ -0,0 +1,89 @@
1
+ import { join } from "path";
2
+ import { mkdtemp, mkdir, writeFile, rm } from "fs/promises";
3
+ import { tmpdir } from "os";
4
+ import {
5
+ clearConfigCache,
6
+ isFolderHidden,
7
+ isFolderSelfHidden,
8
+ } from "../folderConfig.js";
9
+
10
+ let source;
11
+ beforeEach(async () => {
12
+ source = await mkdtemp(join(tmpdir(), "ursa-folderconfig-"));
13
+ clearConfigCache();
14
+ });
15
+ afterEach(async () => {
16
+ await rm(source, { recursive: true, force: true });
17
+ });
18
+
19
+ async function hide(...segments) {
20
+ const dir = join(source, ...segments);
21
+ await mkdir(dir, { recursive: true });
22
+ await writeFile(join(dir, "config.json"), JSON.stringify({ hidden: true }));
23
+ return dir;
24
+ }
25
+
26
+ describe("isFolderHidden", () => {
27
+ it("matches the hidden folder itself", async () => {
28
+ const art = await hide("everdew", "_art");
29
+ expect(isFolderHidden(art, source)).toBe(true);
30
+ });
31
+
32
+ it("matches a descendant folder of a hidden folder", async () => {
33
+ await hide("everdew", "_art");
34
+ const nested = join(source, "everdew", "_art", "prompts", "people");
35
+ await mkdir(nested, { recursive: true });
36
+ expect(isFolderHidden(nested, source)).toBe(true);
37
+ });
38
+
39
+ it("matches file paths, not just directories", async () => {
40
+ // The build filters one list holding both files and directories through
41
+ // this predicate, so a file must resolve via its ancestors.
42
+ await hide("everdew", "_art");
43
+ expect(
44
+ isFolderHidden(join(source, "everdew", "_art", "prompts.md"), source)
45
+ ).toBe(true);
46
+ expect(
47
+ isFolderHidden(join(source, "everdew", "_art", "img", "map.png"), source)
48
+ ).toBe(true);
49
+ });
50
+
51
+ it("leaves siblings and ancestors of a hidden folder visible", async () => {
52
+ await hide("everdew", "_art");
53
+ await mkdir(join(source, "everdew", "people"), { recursive: true });
54
+ expect(isFolderHidden(join(source, "everdew", "people"), source)).toBe(false);
55
+ expect(isFolderHidden(join(source, "everdew"), source)).toBe(false);
56
+ expect(
57
+ isFolderHidden(join(source, "everdew", "people", "alice.md"), source)
58
+ ).toBe(false);
59
+ });
60
+
61
+ it("ignores a config.json that does not set hidden", async () => {
62
+ const dir = join(source, "everdew");
63
+ await mkdir(dir, { recursive: true });
64
+ await writeFile(join(dir, "config.json"), JSON.stringify({ label: "Everdew" }));
65
+ expect(isFolderHidden(dir, source)).toBe(false);
66
+ });
67
+
68
+ it("tolerates a trailing slash on the docroot", async () => {
69
+ const art = await hide("everdew", "_art");
70
+ expect(isFolderHidden(art, source + "/")).toBe(true);
71
+ });
72
+ });
73
+
74
+ describe("isFolderSelfHidden", () => {
75
+ it("is true only for the folder carrying the config, not its descendants", async () => {
76
+ const art = await hide("everdew", "_art");
77
+ const nested = join(art, "prompts");
78
+ await mkdir(nested, { recursive: true });
79
+
80
+ expect(isFolderSelfHidden(art)).toBe(true);
81
+ expect(isFolderSelfHidden(nested)).toBe(false);
82
+ });
83
+
84
+ it("is false for a folder with no config.json", async () => {
85
+ const dir = join(source, "people");
86
+ await mkdir(dir, { recursive: true });
87
+ expect(isFolderSelfHidden(dir)).toBe(false);
88
+ });
89
+ });
@@ -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(/\/$/, '');