@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 +32 -0
- package/README.md +67 -0
- package/bin/ursa.js +14 -3
- package/package.json +1 -1
- package/src/dev.js +29 -0
- package/src/helper/__test__/folderConfig.test.js +89 -0
- package/src/helper/automenu.js +4 -2
- package/src/helper/build/__test__/autoIndex.test.js +67 -0
- package/src/helper/build/autoIndex.js +22 -5
- package/src/helper/folderConfig.js +34 -4
- package/src/jobs/__test__/generateJsonOnly.test.js +154 -0
- package/src/jobs/generate.js +285 -178
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
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
|
+
});
|
package/src/helper/automenu.js
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
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
|
-
|
|
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,
|
|
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
|
-
*
|
|
64
|
-
*
|
|
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
|
|
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(/\/$/, '');
|