@kenjura/ursa 0.90.1 → 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 +78 -0
- package/README.md +102 -0
- package/bin/ursa.js +14 -3
- package/meta/templates/default-template/default.css +1574 -1323
- package/meta/templates/default-template/index.html +175 -129
- package/meta/templates/default-template/lightbox.css +225 -216
- package/meta/templates/default-template/lightbox.js +3 -0
- package/meta/templates/default-template/menu.js +9 -6
- package/meta/templates/default-template/toc-generator.js +58 -0
- package/meta/templates/default-template/widgets.js +88 -15
- package/package.json +2 -1
- package/src/dev.js +30 -1
- package/src/helper/__test__/breadcrumbs.test.js +71 -0
- package/src/helper/__test__/contentHash.test.js +114 -0
- package/src/helper/__test__/folderConfig.test.js +89 -0
- package/src/helper/automenu.js +13 -91
- package/src/helper/breadcrumbs.js +21 -6
- package/src/helper/build/__test__/autoIndex.test.js +135 -1
- package/src/helper/build/autoIndex.js +115 -46
- package/src/helper/build/ursaMetadata.js +3 -26
- package/src/helper/contentHash.js +54 -1
- package/src/helper/folderConfig.js +34 -4
- package/src/helper/menuLabels.js +136 -0
- package/src/helper/ursaVersion.js +26 -0
- package/src/jobs/__test__/generateJsonOnly.test.js +154 -0
- package/src/jobs/generate.js +302 -181
- package/meta/default.css +0 -1206
- package/meta/menu.js +0 -898
- package/meta/sectionify.js +0 -46
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { join } from "path";
|
|
2
|
+
import { getFolderConfig } from "./folderConfig.js";
|
|
3
|
+
import { toDisplayName, getFolderLabel } from "./menuLabels.js";
|
|
2
4
|
|
|
3
5
|
/**
|
|
4
6
|
* Generate breadcrumb navigation HTML from a document's path.
|
|
@@ -6,9 +8,12 @@ import { toTitleCase } from "./build/titleCase.js";
|
|
|
6
8
|
* @param {string} dir - Directory relative to source root, e.g. "settings/eberron/" or "/"
|
|
7
9
|
* @param {string} base - Filename without extension, e.g. "index" or "places"
|
|
8
10
|
* @param {object} [fileMeta] - Parsed frontmatter (used for current-page label override)
|
|
11
|
+
* @param {string|null} [sourceRoot] - Absolute path of the docroot. With it, folder
|
|
12
|
+
* segments are named the way the menu names them (`menu-label` frontmatter, then
|
|
13
|
+
* config.json `label`); without it they fall back to the prettified folder name.
|
|
9
14
|
* @returns {string} Breadcrumb HTML string, or empty string if not applicable
|
|
10
15
|
*/
|
|
11
|
-
export function generateBreadcrumbs(dir, base, fileMeta) {
|
|
16
|
+
export function generateBreadcrumbs(dir, base, fileMeta, sourceRoot = null) {
|
|
12
17
|
const segments = dir.split('/').filter(Boolean);
|
|
13
18
|
const isIndexFile = (base === 'index' || base === 'home');
|
|
14
19
|
|
|
@@ -25,10 +30,20 @@ export function generateBreadcrumbs(dir, base, fileMeta) {
|
|
|
25
30
|
const seg = allSegments[i];
|
|
26
31
|
const isLast = i === allSegments.length - 1;
|
|
27
32
|
|
|
28
|
-
//
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
33
|
+
// Every segment but the last is a folder, and so is the last one when this
|
|
34
|
+
// is a folder's index page. Those get the menu's label. The last segment of
|
|
35
|
+
// a regular document is the document itself, which keeps its own
|
|
36
|
+
// frontmatter override.
|
|
37
|
+
const isFolderSegment = !isLast || isIndexFile;
|
|
38
|
+
let label;
|
|
39
|
+
if (isLast && !isFolderSegment) {
|
|
40
|
+
label = fileMeta?.['menu-label'] || fileMeta?.title || toDisplayName(seg);
|
|
41
|
+
} else if (sourceRoot) {
|
|
42
|
+
const folderPath = join(sourceRoot, ...allSegments.slice(0, i + 1));
|
|
43
|
+
label = getFolderLabel(folderPath, getFolderConfig(folderPath), seg);
|
|
44
|
+
} else {
|
|
45
|
+
label = toDisplayName(seg);
|
|
46
|
+
}
|
|
32
47
|
|
|
33
48
|
if (isLast) {
|
|
34
49
|
parts.push(`<span class="breadcrumb-current" aria-current="page">${label}</span>`);
|
|
@@ -2,7 +2,8 @@ import { join } from "path";
|
|
|
2
2
|
import { mkdtemp, mkdir, writeFile, rm, readFile } from "fs/promises";
|
|
3
3
|
import { existsSync } from "fs";
|
|
4
4
|
import { tmpdir } from "os";
|
|
5
|
-
import { generateAutoIndices } from "../autoIndex.js";
|
|
5
|
+
import { generateAutoIndices, generateAutoIndexHtmlFromSource } from "../autoIndex.js";
|
|
6
|
+
import { clearConfigCache } from "../../folderConfig.js";
|
|
6
7
|
|
|
7
8
|
let tempDir;
|
|
8
9
|
let source;
|
|
@@ -93,3 +94,136 @@ describe("generateAutoIndices with empty source folders", () => {
|
|
|
93
94
|
expect(rootIndex).toContain('<a href="docs/index.html">');
|
|
94
95
|
});
|
|
95
96
|
});
|
|
97
|
+
|
|
98
|
+
describe("auto-index naming matches the automenu", () => {
|
|
99
|
+
it("uses menu-label from a folder's index frontmatter, and config.json when there is no index", async () => {
|
|
100
|
+
// bnw has a real index.md carrying the label; hfr has no index at all, so
|
|
101
|
+
// it falls back to config.json — the same two-step the automenu uses.
|
|
102
|
+
await mkdir(join(source, "bnw"));
|
|
103
|
+
await writeFile(
|
|
104
|
+
join(source, "bnw", "index.md"),
|
|
105
|
+
"---\nmenu-label: 'BNW - Brave New World'\n---\n"
|
|
106
|
+
);
|
|
107
|
+
await writeFile(join(source, "bnw", "quests.md"), "# Quests\n");
|
|
108
|
+
await mkdir(join(source, "hfr"));
|
|
109
|
+
await writeFile(
|
|
110
|
+
join(source, "hfr", "config.json"),
|
|
111
|
+
JSON.stringify({ label: "HFR - Hyacinth: Fury Road" })
|
|
112
|
+
);
|
|
113
|
+
await writeFile(join(source, "hfr", "hfr.md"), "# Hyacinth\n");
|
|
114
|
+
|
|
115
|
+
const html = await generateAutoIndexHtmlFromSource(source, 1);
|
|
116
|
+
|
|
117
|
+
expect(html).toContain('<a href="bnw/index.html">BNW - Brave New World</a>');
|
|
118
|
+
expect(html).toContain('<a href="hfr/index.html">HFR - Hyacinth: Fury Road</a>');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("uses menu-label on individual documents and menu-sort-as for ordering", async () => {
|
|
122
|
+
await writeFile(
|
|
123
|
+
join(source, "zebra.md"),
|
|
124
|
+
"---\nmenu-label: 'ZED - Zebra'\nmenu-sort-as: 'aardvark'\n---\n# Zebra\n"
|
|
125
|
+
);
|
|
126
|
+
await writeFile(join(source, "middle.md"), "# Middle\n");
|
|
127
|
+
|
|
128
|
+
const html = await generateAutoIndexHtmlFromSource(source, 1);
|
|
129
|
+
|
|
130
|
+
expect(html).toContain('<a href="zebra.html">ZED - Zebra</a>');
|
|
131
|
+
// Sorted by menu-sort-as ("aardvark"), not by filename ("zebra")
|
|
132
|
+
expect(html.indexOf("ZED - Zebra")).toBeLessThan(html.indexOf("Middle"));
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("preserves interior capitalization instead of title-casing it away", async () => {
|
|
136
|
+
await mkdir(join(source, "SoL"));
|
|
137
|
+
await writeFile(join(source, "SoL", "notes.md"), "# Notes\n");
|
|
138
|
+
|
|
139
|
+
const html = await generateAutoIndexHtmlFromSource(source, 1);
|
|
140
|
+
|
|
141
|
+
expect(html).toContain('<a href="SoL/index.html">SoL</a>');
|
|
142
|
+
});
|
|
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
|
+
});
|
|
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
|
+
});
|
|
@@ -5,10 +5,18 @@ import { basename, dirname, extname, join } from "path";
|
|
|
5
5
|
import { outputFile } from "fs-extra";
|
|
6
6
|
import { findStyleCss, findAllStyleCss } from "../findStyleCss.js";
|
|
7
7
|
import { findAllScriptJs } from "../findScriptJs.js";
|
|
8
|
-
import { toTitleCase } from "./titleCase.js";
|
|
9
8
|
import { addTimestampToHtmlStaticRefs } from "./cacheBust.js";
|
|
10
9
|
import { isMetadataOnly, extractMetadata, getAutoIndexConfig } from "../metadataExtractor.js";
|
|
11
10
|
import { getCustomMenuForFile } from "./menu.js";
|
|
11
|
+
import { getFolderConfig, isFolderSelfHidden } from "../folderConfig.js";
|
|
12
|
+
import {
|
|
13
|
+
toDisplayName,
|
|
14
|
+
getFolderLabel,
|
|
15
|
+
getFolderSortKey,
|
|
16
|
+
getMenuSortAsFromFile,
|
|
17
|
+
getFileLabel,
|
|
18
|
+
findSourceDocument,
|
|
19
|
+
} from "../menuLabels.js";
|
|
12
20
|
import { generateBreadcrumbs } from "../breadcrumbs.js";
|
|
13
21
|
import { bundleDocumentCss, bundleDocumentJs } from "../assetBundler.js";
|
|
14
22
|
|
|
@@ -18,11 +26,18 @@ const OUTPUT_DOC_EXTENSIONS = ['.html'];
|
|
|
18
26
|
|
|
19
27
|
/**
|
|
20
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
|
+
*
|
|
21
35
|
* @param {string} dir - Directory path to check
|
|
22
36
|
* @param {string[]} extensions - File extensions that count as documents
|
|
37
|
+
* @param {string|null} [sourceDir=dir] - Matching source directory, for hidden lookups
|
|
23
38
|
* @returns {Promise<boolean>} True if the directory (or any subdirectory) contains at least one document
|
|
24
39
|
*/
|
|
25
|
-
async function directoryHasDocuments(dir, extensions) {
|
|
40
|
+
async function directoryHasDocuments(dir, extensions, sourceDir = dir) {
|
|
26
41
|
try {
|
|
27
42
|
const children = await readdir(dir, { withFileTypes: true });
|
|
28
43
|
for (const child of children) {
|
|
@@ -30,7 +45,9 @@ async function directoryHasDocuments(dir, extensions) {
|
|
|
30
45
|
const fullPath = join(dir, child.name);
|
|
31
46
|
if (child.isDirectory()) {
|
|
32
47
|
if (child.name === 'img') continue;
|
|
33
|
-
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;
|
|
34
51
|
} else {
|
|
35
52
|
const ext = extname(child.name).toLowerCase();
|
|
36
53
|
if (extensions.includes(ext)) return true;
|
|
@@ -40,6 +57,44 @@ async function directoryHasDocuments(dir, extensions) {
|
|
|
40
57
|
return false;
|
|
41
58
|
}
|
|
42
59
|
|
|
60
|
+
/**
|
|
61
|
+
* Resolve the label and sort key for one auto-index entry, using the same
|
|
62
|
+
* rules as the site-wide automenu: `menu-label` frontmatter wins, then
|
|
63
|
+
* config.json `label` for folders, then the prettified file/folder name.
|
|
64
|
+
*
|
|
65
|
+
* @param {boolean} isDir - Whether the entry is a directory
|
|
66
|
+
* @param {string} baseName - Name without extension
|
|
67
|
+
* @param {string|null} sourceDir - Source directory holding this entry, if known
|
|
68
|
+
* @returns {{label: string, sortKey: string}}
|
|
69
|
+
*/
|
|
70
|
+
function resolveEntryNaming(isDir, baseName, sourceDir) {
|
|
71
|
+
if (!sourceDir) {
|
|
72
|
+
return { label: toDisplayName(baseName), sortKey: baseName };
|
|
73
|
+
}
|
|
74
|
+
if (isDir) {
|
|
75
|
+
const childDir = join(sourceDir, baseName);
|
|
76
|
+
return {
|
|
77
|
+
label: getFolderLabel(childDir, getFolderConfig(childDir), baseName),
|
|
78
|
+
sortKey: getFolderSortKey(childDir) || baseName,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
const sourceFile = findSourceDocument(sourceDir, baseName);
|
|
82
|
+
return {
|
|
83
|
+
label: getFileLabel(sourceFile, baseName),
|
|
84
|
+
sortKey: (sourceFile && getMenuSortAsFromFile(sourceFile)) || baseName,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Sort auto-index entries the way the automenu does: folders first, then by
|
|
90
|
+
* sort key (case-insensitive).
|
|
91
|
+
*/
|
|
92
|
+
function compareEntries(a, b) {
|
|
93
|
+
if (a.isDir && !b.isDir) return -1;
|
|
94
|
+
if (!a.isDir && b.isDir) return 1;
|
|
95
|
+
return a.sortKey.toLowerCase().localeCompare(b.sortKey.toLowerCase());
|
|
96
|
+
}
|
|
97
|
+
|
|
43
98
|
/**
|
|
44
99
|
* Generate auto-index HTML content for a directory from the OUTPUT folder
|
|
45
100
|
* (used by fallback auto-index generation after all files are generated)
|
|
@@ -47,9 +102,12 @@ async function directoryHasDocuments(dir, extensions) {
|
|
|
47
102
|
* @param {number} depth - How deep to recurse (1 = current level only, 2 = current + children, etc.)
|
|
48
103
|
* @param {number} [currentDepth=0] - Current recursion depth (internal use)
|
|
49
104
|
* @param {string} [pathPrefix=''] - Path prefix for generating correct hrefs (internal use)
|
|
105
|
+
* @param {string|null} [sourceDir=null] - Matching source directory, so `menu-label`
|
|
106
|
+
* frontmatter and config.json labels can be honored. Without it, entries fall
|
|
107
|
+
* back to their prettified file names.
|
|
50
108
|
* @returns {Promise<string>} HTML content for the auto-index
|
|
51
109
|
*/
|
|
52
|
-
export async function generateAutoIndexHtml(dir, depth = 1, currentDepth = 0, pathPrefix = '') {
|
|
110
|
+
export async function generateAutoIndexHtml(dir, depth = 1, currentDepth = 0, pathPrefix = '', sourceDir = null) {
|
|
53
111
|
try {
|
|
54
112
|
const children = await readdir(dir, { withFileTypes: true });
|
|
55
113
|
|
|
@@ -62,15 +120,18 @@ export async function generateAutoIndexHtml(dir, depth = 1, currentDepth = 0, pa
|
|
|
62
120
|
if (child.name === 'index.html') return false;
|
|
63
121
|
// Skip img folders (contain images, not content)
|
|
64
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;
|
|
65
126
|
// Include directories and html files
|
|
66
127
|
return child.isDirectory() || child.name.endsWith('.html');
|
|
67
128
|
})
|
|
68
|
-
.
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
129
|
+
.map(child => {
|
|
130
|
+
const isDir = child.isDirectory();
|
|
131
|
+
const baseName = isDir ? child.name : child.name.replace(/\.html$/, '');
|
|
132
|
+
return { child, isDir, baseName, ...resolveEntryNaming(isDir, baseName, sourceDir) };
|
|
133
|
+
})
|
|
134
|
+
.sort(compareEntries);
|
|
74
135
|
|
|
75
136
|
if (filteredChildren.length === 0) {
|
|
76
137
|
return '';
|
|
@@ -78,26 +139,27 @@ export async function generateAutoIndexHtml(dir, depth = 1, currentDepth = 0, pa
|
|
|
78
139
|
|
|
79
140
|
const items = [];
|
|
80
141
|
|
|
81
|
-
for (const child of filteredChildren) {
|
|
82
|
-
const isDir = child.isDirectory();
|
|
142
|
+
for (const { child, isDir, label } of filteredChildren) {
|
|
83
143
|
// Skip directories that contain no documents
|
|
84
144
|
if (isDir) {
|
|
85
145
|
const childDir = join(dir, child.name);
|
|
86
|
-
|
|
146
|
+
const childSource = sourceDir ? join(sourceDir, child.name) : null;
|
|
147
|
+
if (!await directoryHasDocuments(childDir, OUTPUT_DOC_EXTENSIONS, childSource)) continue;
|
|
87
148
|
}
|
|
88
|
-
const name = isDir ? child.name : child.name.replace('.html', '');
|
|
89
149
|
// Use pathPrefix to ensure hrefs are correct relative to the document root
|
|
90
150
|
const childPath = pathPrefix ? `${pathPrefix}/${child.name}` : child.name;
|
|
91
151
|
const href = isDir ? `${childPath}/index.html` : (pathPrefix ? `${pathPrefix}/${child.name}` : child.name);
|
|
92
|
-
const displayName = toTitleCase(name);
|
|
93
152
|
const icon = isDir ? '📁' : '📄';
|
|
94
153
|
|
|
95
|
-
let itemHtml = `<li>${icon} <a href="${href}">${
|
|
154
|
+
let itemHtml = `<li>${icon} <a href="${href}">${label}</a>`;
|
|
96
155
|
|
|
97
156
|
// If this is a directory and we need to go deeper, recurse
|
|
98
157
|
if (isDir && currentDepth + 1 < depth) {
|
|
99
158
|
const childDir = join(dir, child.name);
|
|
100
|
-
const childHtml = await generateAutoIndexHtml(
|
|
159
|
+
const childHtml = await generateAutoIndexHtml(
|
|
160
|
+
childDir, depth, currentDepth + 1, childPath,
|
|
161
|
+
sourceDir ? join(sourceDir, child.name) : null
|
|
162
|
+
);
|
|
101
163
|
if (childHtml) {
|
|
102
164
|
itemHtml += `\n${childHtml}`;
|
|
103
165
|
}
|
|
@@ -137,15 +199,17 @@ export async function generateAutoIndexHtmlFromSource(sourceDir, depth = 1, curr
|
|
|
137
199
|
if (child.name.match(/^index\.(md|mdx|txt|yml|html)$/i)) return false;
|
|
138
200
|
// Skip img folders (contain images, not content)
|
|
139
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;
|
|
140
204
|
// Include directories and article files (md, mdx, txt, yml, html)
|
|
141
205
|
return child.isDirectory() || child.name.match(/\.(md|mdx|txt|yml|html)$/i);
|
|
142
206
|
})
|
|
143
|
-
.
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
207
|
+
.map(child => {
|
|
208
|
+
const isDir = child.isDirectory();
|
|
209
|
+
const baseName = isDir ? child.name : basename(child.name, extname(child.name));
|
|
210
|
+
return { child, isDir, baseName, ...resolveEntryNaming(isDir, baseName, sourceDir) };
|
|
211
|
+
})
|
|
212
|
+
.sort(compareEntries);
|
|
149
213
|
|
|
150
214
|
if (filteredChildren.length === 0) {
|
|
151
215
|
return '';
|
|
@@ -153,24 +217,19 @@ export async function generateAutoIndexHtmlFromSource(sourceDir, depth = 1, curr
|
|
|
153
217
|
|
|
154
218
|
const items = [];
|
|
155
219
|
|
|
156
|
-
for (const child of filteredChildren) {
|
|
157
|
-
const isDir = child.isDirectory();
|
|
220
|
+
for (const { child, isDir, baseName, label } of filteredChildren) {
|
|
158
221
|
// Skip directories that contain no documents
|
|
159
222
|
if (isDir) {
|
|
160
223
|
const childDir = join(sourceDir, child.name);
|
|
161
224
|
if (!await directoryHasDocuments(childDir, SOURCE_DOC_EXTENSIONS)) continue;
|
|
162
225
|
}
|
|
163
|
-
// Get name without extension for display
|
|
164
|
-
const ext = isDir ? '' : extname(child.name);
|
|
165
|
-
const nameWithoutExt = isDir ? child.name : basename(child.name, ext);
|
|
166
226
|
// Generate href - directories link to folder/index.html, files convert to .html
|
|
167
227
|
// Use pathPrefix to ensure hrefs are correct relative to the document root
|
|
168
228
|
const childPath = pathPrefix ? `${pathPrefix}/${child.name}` : child.name;
|
|
169
|
-
const href = isDir ? `${childPath}/index.html` : `${pathPrefix ? pathPrefix + '/' : ''}${
|
|
170
|
-
const displayName = toTitleCase(nameWithoutExt);
|
|
229
|
+
const href = isDir ? `${childPath}/index.html` : `${pathPrefix ? pathPrefix + '/' : ''}${baseName}.html`;
|
|
171
230
|
const icon = isDir ? '📁' : '📄';
|
|
172
231
|
|
|
173
|
-
let itemHtml = `<li>${icon} <a href="${href}">${
|
|
232
|
+
let itemHtml = `<li>${icon} <a href="${href}">${label}</a>`;
|
|
174
233
|
|
|
175
234
|
// If this is a directory and we need to go deeper, recurse
|
|
176
235
|
if (isDir && currentDepth + 1 < depth) {
|
|
@@ -313,28 +372,34 @@ export async function generateAutoIndices(output, directories, source, templates
|
|
|
313
372
|
const children = await readdir(dir, { withFileTypes: true });
|
|
314
373
|
|
|
315
374
|
// Filter to only include relevant files and folders
|
|
316
|
-
const filteredItems = children
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
375
|
+
const filteredItems = children
|
|
376
|
+
.filter(child => {
|
|
377
|
+
// Skip hidden files and index alternates we just checked
|
|
378
|
+
if (child.name.startsWith('.')) return false;
|
|
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;
|
|
382
|
+
// Include directories and html files
|
|
383
|
+
return child.isDirectory() || child.name.endsWith('.html');
|
|
384
|
+
})
|
|
385
|
+
.map(child => {
|
|
386
|
+
const isDir = child.isDirectory();
|
|
387
|
+
const baseName = isDir ? child.name : child.name.replace(/\.html$/, '');
|
|
388
|
+
return { child, isDir, baseName, ...resolveEntryNaming(isDir, baseName, sourceDir) };
|
|
389
|
+
})
|
|
390
|
+
.sort(compareEntries);
|
|
323
391
|
|
|
324
392
|
// Build items, skipping directories with no documents
|
|
325
393
|
const items = [];
|
|
326
|
-
for (const child of filteredItems) {
|
|
327
|
-
const isDir = child.isDirectory();
|
|
394
|
+
for (const { child, isDir, label } of filteredItems) {
|
|
328
395
|
if (isDir) {
|
|
329
396
|
const childDir = join(dir, child.name);
|
|
330
|
-
if (!await directoryHasDocuments(childDir, OUTPUT_DOC_EXTENSIONS)) continue;
|
|
397
|
+
if (!await directoryHasDocuments(childDir, OUTPUT_DOC_EXTENSIONS, join(sourceDir, child.name))) continue;
|
|
331
398
|
}
|
|
332
|
-
const name = isDir ? child.name : child.name.replace('.html', '');
|
|
333
399
|
// For directories, link to /folder/index.html; for files, use the filename directly
|
|
334
400
|
const href = isDir ? `${child.name}/index.html` : child.name;
|
|
335
|
-
const displayName = toTitleCase(name);
|
|
336
401
|
const icon = isDir ? '📁' : '📄';
|
|
337
|
-
items.push(`<li>${icon} <a href="${href}">${
|
|
402
|
+
items.push(`<li>${icon} <a href="${href}">${label}</a></li>`);
|
|
338
403
|
}
|
|
339
404
|
|
|
340
405
|
if (items.length === 0) {
|
|
@@ -342,12 +407,16 @@ export async function generateAutoIndices(output, directories, source, templates
|
|
|
342
407
|
continue;
|
|
343
408
|
}
|
|
344
409
|
|
|
345
|
-
|
|
410
|
+
// The page's own heading and <title> follow the same naming rules, so a
|
|
411
|
+
// folder labelled "BNW - Brave New World" in the menu is not "Bnw" here.
|
|
412
|
+
const folderDisplayName = dir === outputNorm
|
|
413
|
+
? 'Home'
|
|
414
|
+
: getFolderLabel(sourceDir, getFolderConfig(sourceDir), folderName);
|
|
346
415
|
|
|
347
416
|
// Generate breadcrumbs for auto-index pages
|
|
348
417
|
const relDir = dir.replace(outputNorm, '').replace(/^\//, '');
|
|
349
418
|
const breadcrumbDir = relDir ? relDir + '/' : '/';
|
|
350
|
-
const breadcrumbHtml = generateBreadcrumbs(breadcrumbDir, 'index', null);
|
|
419
|
+
const breadcrumbHtml = generateBreadcrumbs(breadcrumbDir, 'index', null, sourceNorm);
|
|
351
420
|
|
|
352
421
|
const indexHtml = `${breadcrumbHtml}<h1>${folderDisplayName}</h1>\n<ul class="auto-index">\n${items.join('\n')}\n</ul>`;
|
|
353
422
|
|
|
@@ -1,27 +1,8 @@
|
|
|
1
1
|
// Helper for building the _ursa_metadata field embedded in generated JSON files
|
|
2
2
|
import { existsSync } from "fs";
|
|
3
3
|
import { readFile } from "fs/promises";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* Read the ursa version from ursa's own package.json
|
|
9
|
-
* @returns {Promise<string>} The ursa version, or 'unknown' if it can't be read
|
|
10
|
-
*/
|
|
11
|
-
async function getUrsaVersion() {
|
|
12
|
-
try {
|
|
13
|
-
// From src/helper/build/ursaMetadata.js, go up to the package root
|
|
14
|
-
const currentDir = dirname(new URL(import.meta.url).pathname);
|
|
15
|
-
const ursaPackagePath = resolve(currentDir, "..", "..", "..", "package.json");
|
|
16
|
-
if (existsSync(ursaPackagePath)) {
|
|
17
|
-
const ursaPackage = JSON.parse(await readFile(ursaPackagePath, "utf8"));
|
|
18
|
-
if (ursaPackage.version) return ursaPackage.version;
|
|
19
|
-
}
|
|
20
|
-
} catch (e) {
|
|
21
|
-
console.error(`Error reading ursa package.json: ${e.message}`);
|
|
22
|
-
}
|
|
23
|
-
return "unknown";
|
|
24
|
-
}
|
|
4
|
+
import { join, resolve } from "path";
|
|
5
|
+
import { getUrsaVersion } from "../ursaVersion.js";
|
|
25
6
|
|
|
26
7
|
/**
|
|
27
8
|
* Read the documentation repo version from its package.json.
|
|
@@ -54,9 +35,5 @@ async function getDocVersion(_source) {
|
|
|
54
35
|
* @returns {Promise<{ursaVersion: string, docVersion: string}>}
|
|
55
36
|
*/
|
|
56
37
|
export async function getUrsaMetadata(_source) {
|
|
57
|
-
|
|
58
|
-
getUrsaVersion(),
|
|
59
|
-
getDocVersion(_source),
|
|
60
|
-
]);
|
|
61
|
-
return { ursaVersion, docVersion };
|
|
38
|
+
return { ursaVersion: getUrsaVersion(), docVersion: await getDocVersion(_source) };
|
|
62
39
|
}
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { createHash } from 'crypto';
|
|
2
|
-
import { readFile, writeFile, mkdir } from 'fs/promises';
|
|
2
|
+
import { readFile, writeFile, mkdir, rm } from 'fs/promises';
|
|
3
3
|
import { existsSync } from 'fs';
|
|
4
4
|
import { dirname, join } from 'path';
|
|
5
|
+
import { getUrsaVersion } from './ursaVersion.js';
|
|
5
6
|
|
|
6
7
|
const URSA_DIR = '.ursa';
|
|
7
8
|
const HASH_CACHE_FILE = 'content-hashes.json';
|
|
9
|
+
const CACHE_STAMP_FILE = 'cache-stamp.json';
|
|
8
10
|
|
|
9
11
|
/**
|
|
10
12
|
* Get the path to the .ursa directory for a given source directory
|
|
@@ -13,6 +15,57 @@ export function getUrsaDir(sourceDir) {
|
|
|
13
15
|
return join(sourceDir, URSA_DIR);
|
|
14
16
|
}
|
|
15
17
|
|
|
18
|
+
/**
|
|
19
|
+
* Stamp `.ursa/` with the running ursa version, discarding the whole directory
|
|
20
|
+
* first if it was written by a different one.
|
|
21
|
+
*
|
|
22
|
+
* Hash-skipping compares *source* content only. It says nothing about the code
|
|
23
|
+
* that turned that source into output, so a cache warmed by an older ursa keeps
|
|
24
|
+
* whole documents from being re-rendered even after the templates, renderers and
|
|
25
|
+
* asset bundles that produced them have changed — the symptom users hit as
|
|
26
|
+
* "I had to run --clean again". Run this before loading any cache.
|
|
27
|
+
*
|
|
28
|
+
* On a first build there is nothing to discard, and `reset` is false.
|
|
29
|
+
*
|
|
30
|
+
* @param {string} sourceDir - Source directory root
|
|
31
|
+
* @param {string} [version] - Version to stamp with; defaults to ursa's own
|
|
32
|
+
* @returns {Promise<{reset: boolean, previous: string|null, version: string}>}
|
|
33
|
+
*/
|
|
34
|
+
export async function enforceCacheVersion(sourceDir, version = getUrsaVersion()) {
|
|
35
|
+
const ursaDir = getUrsaDir(sourceDir);
|
|
36
|
+
const stampPath = join(ursaDir, CACHE_STAMP_FILE);
|
|
37
|
+
|
|
38
|
+
let previous = null;
|
|
39
|
+
try {
|
|
40
|
+
if (existsSync(stampPath)) {
|
|
41
|
+
const stamp = JSON.parse(await readFile(stampPath, 'utf8'));
|
|
42
|
+
previous = typeof stamp?.ursaVersion === 'string' ? stamp.ursaVersion : null;
|
|
43
|
+
}
|
|
44
|
+
} catch (e) {
|
|
45
|
+
// An unreadable stamp tells us nothing about what wrote the cache, so treat
|
|
46
|
+
// it the same as a mismatch rather than trusting the caches beside it.
|
|
47
|
+
previous = null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (previous === version) return { reset: false, previous, version };
|
|
51
|
+
|
|
52
|
+
// A cache directory with no stamp predates stamping (or was hand-edited);
|
|
53
|
+
// either way its contents were not written by this ursa.
|
|
54
|
+
const hadCache = existsSync(ursaDir);
|
|
55
|
+
if (hadCache) await rm(ursaDir, { recursive: true, force: true });
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
await mkdir(ursaDir, { recursive: true });
|
|
59
|
+
await writeFile(stampPath, JSON.stringify({ ursaVersion: version }, null, 2));
|
|
60
|
+
} catch (e) {
|
|
61
|
+
// A cache we cannot stamp is a cache we will discard again next run:
|
|
62
|
+
// correct, just slower. Not worth failing the build over.
|
|
63
|
+
console.warn('Could not write cache stamp:', e.message);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return { reset: hadCache, previous, version };
|
|
67
|
+
}
|
|
68
|
+
|
|
16
69
|
/**
|
|
17
70
|
* Generate a short hash of content
|
|
18
71
|
*/
|
|
@@ -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(/\/$/, '');
|