@kenjura/ursa 0.90.0 → 0.93.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 +54 -0
- package/README.md +35 -0
- 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 +5 -3
- package/src/dev.js +1 -1
- package/src/helper/__test__/breadcrumbs.test.js +71 -0
- package/src/helper/__test__/contentHash.test.js +114 -0
- package/src/helper/automenu.js +9 -89
- package/src/helper/breadcrumbs.js +21 -6
- package/src/helper/build/__test__/autoIndex.test.js +68 -1
- package/src/helper/build/autoIndex.js +94 -42
- package/src/helper/build/ursaMetadata.js +3 -26
- package/src/helper/contentHash.js +54 -1
- package/src/helper/menuLabels.js +136 -0
- package/src/helper/ursaVersion.js +26 -0
- package/src/jobs/generate.js +17 -3
- package/meta/default.css +0 -1206
- package/meta/menu.js +0 -898
- package/meta/sectionify.js +0 -46
|
@@ -6,10 +6,15 @@
|
|
|
6
6
|
* dropdown panels. One widget can be open per side at a time.
|
|
7
7
|
*
|
|
8
8
|
* Widget state (open/closed) is persisted in localStorage so it survives page reloads.
|
|
9
|
+
*
|
|
10
|
+
* An open panel carries data-active-widget naming whichever widget is showing,
|
|
11
|
+
* and loses it when it closes. The panels are shared containers, so this is what
|
|
12
|
+
* lets CSS — Ursa's own and a site's — tell one widget's panel from another's.
|
|
13
|
+
* See "Styling one widget at a time" in the README.
|
|
9
14
|
*
|
|
10
15
|
* Built-in widgets:
|
|
11
16
|
* Left: Recent Activity (open by default)
|
|
12
|
-
* Right: TOC, Search, Profile
|
|
17
|
+
* Right: TOC (open by default, persistent), Search, Profile
|
|
13
18
|
*/
|
|
14
19
|
class WidgetManager {
|
|
15
20
|
constructor() {
|
|
@@ -19,8 +24,31 @@ class WidgetManager {
|
|
|
19
24
|
this.activeRight = null;
|
|
20
25
|
this.activeLeft = null;
|
|
21
26
|
|
|
22
|
-
// Widgets that default to open on first visit
|
|
23
|
-
|
|
27
|
+
// Widgets that default to open on first visit. The TOC is here because it
|
|
28
|
+
// is reference furniture rather than a tool you go and fetch: it belongs
|
|
29
|
+
// beside the article the way page numbers belong on a page. It costs
|
|
30
|
+
// nothing to leave up — the stylesheet gives it the right margin, and
|
|
31
|
+
// takes it away again on a viewport with no margin to spare.
|
|
32
|
+
this.defaultOpen = new Set(['recent-activity', 'toc']);
|
|
33
|
+
|
|
34
|
+
// Widgets that are furniture rather than a drawer. Two things follow.
|
|
35
|
+
//
|
|
36
|
+
// A click elsewhere on the page does not dismiss them. Light dismissal is
|
|
37
|
+
// right for something you pull open, glance at and are done with, but the
|
|
38
|
+
// first half of following a link is a click on the page — so for a widget
|
|
39
|
+
// meant to stay up it fired constantly, and, because dismissal is recorded,
|
|
40
|
+
// it wrote down "the reader closed this" every time. One click on an
|
|
41
|
+
// article and the TOC was off for good, on that page and every page after.
|
|
42
|
+
// Escape is out for the same reason: it would be remembered.
|
|
43
|
+
//
|
|
44
|
+
// And they own their side of the nav. Another widget opening there is
|
|
45
|
+
// borrowing it, not replacing them — the loan is not recorded as a closure,
|
|
46
|
+
// and they come back when it is handed back.
|
|
47
|
+
//
|
|
48
|
+
// Which leaves the button in the nav and the panel's own close button as
|
|
49
|
+
// the only two things that decide whether a persistent widget is showing.
|
|
50
|
+
// Those are unambiguous, and those are remembered.
|
|
51
|
+
this.persistent = new Set(['toc']);
|
|
24
52
|
|
|
25
53
|
if (this.buttons.length === 0) return;
|
|
26
54
|
|
|
@@ -86,12 +114,14 @@ class WidgetManager {
|
|
|
86
114
|
document.addEventListener('click', (e) => {
|
|
87
115
|
// Close right-side widget if click is outside
|
|
88
116
|
if (this.activeRight && this.dropdownRight &&
|
|
117
|
+
!this.persistent.has(this.activeRight) &&
|
|
89
118
|
!this.dropdownRight.contains(e.target) &&
|
|
90
119
|
!e.target.closest('.widget-button')) {
|
|
91
120
|
this.close('right');
|
|
92
121
|
}
|
|
93
122
|
// Close left-side widget if click is outside
|
|
94
123
|
if (this.activeLeft && this.dropdownLeft &&
|
|
124
|
+
!this.persistent.has(this.activeLeft) &&
|
|
95
125
|
!this.dropdownLeft.contains(e.target) &&
|
|
96
126
|
!e.target.closest('.widget-button')) {
|
|
97
127
|
this.close('left');
|
|
@@ -101,8 +131,8 @@ class WidgetManager {
|
|
|
101
131
|
// Close on Escape
|
|
102
132
|
document.addEventListener('keydown', (e) => {
|
|
103
133
|
if (e.key === 'Escape') {
|
|
104
|
-
if (this.activeRight) this.close('right');
|
|
105
|
-
if (this.activeLeft) this.close('left');
|
|
134
|
+
if (this.activeRight && !this.persistent.has(this.activeRight)) this.close('right');
|
|
135
|
+
if (this.activeLeft && !this.persistent.has(this.activeLeft)) this.close('left');
|
|
106
136
|
}
|
|
107
137
|
});
|
|
108
138
|
|
|
@@ -130,6 +160,29 @@ class WidgetManager {
|
|
|
130
160
|
} catch (e) { /* localStorage not available */ }
|
|
131
161
|
}
|
|
132
162
|
|
|
163
|
+
/**
|
|
164
|
+
* Whether a widget should be showing, as far as the reader's own choices go:
|
|
165
|
+
* what they last decided, or the default if they have not decided anything.
|
|
166
|
+
*/
|
|
167
|
+
wantsToBeOpen(widgetName) {
|
|
168
|
+
let saved;
|
|
169
|
+
try {
|
|
170
|
+
saved = localStorage.getItem(`ursa-widget-${widgetName}`);
|
|
171
|
+
} catch (e) { /* localStorage not available */ }
|
|
172
|
+
|
|
173
|
+
return saved === 'open' || (saved == null && this.defaultOpen.has(widgetName));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* The widget that owns a side of the nav when nothing else is using it.
|
|
178
|
+
*/
|
|
179
|
+
residentOf(side) {
|
|
180
|
+
for (const widgetName of this.persistent) {
|
|
181
|
+
if (this.getSide(widgetName) === side) return widgetName;
|
|
182
|
+
}
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
|
|
133
186
|
/**
|
|
134
187
|
* Restore widget states from localStorage.
|
|
135
188
|
* For widgets with no saved state, use their default (defaultOpen set).
|
|
@@ -140,19 +193,24 @@ class WidgetManager {
|
|
|
140
193
|
this.buttons.forEach(btn => widgetNames.add(btn.dataset.widget));
|
|
141
194
|
|
|
142
195
|
for (const widgetName of widgetNames) {
|
|
143
|
-
|
|
144
|
-
let saved;
|
|
145
|
-
try {
|
|
146
|
-
saved = localStorage.getItem(key);
|
|
147
|
-
} catch (e) { /* localStorage not available */ }
|
|
148
|
-
|
|
149
|
-
const shouldOpen = saved === 'open' || (saved === null && this.defaultOpen.has(widgetName));
|
|
150
|
-
if (shouldOpen) {
|
|
196
|
+
if (this.wantsToBeOpen(widgetName) && this.hasContent(widgetName)) {
|
|
151
197
|
this.open(widgetName);
|
|
152
198
|
}
|
|
153
199
|
}
|
|
154
200
|
}
|
|
155
201
|
|
|
202
|
+
/**
|
|
203
|
+
* Whether a widget has anything to show. A widget that has hidden its own
|
|
204
|
+
* button has nothing — the TOC generator does exactly that on a page with no
|
|
205
|
+
* headings — and restoring it would put an empty panel on screen with no
|
|
206
|
+
* button to shut it again. Only relevant on restore: a widget the reader
|
|
207
|
+
* opens by hand plainly has a button to have clicked.
|
|
208
|
+
*/
|
|
209
|
+
hasContent(widgetName) {
|
|
210
|
+
const btn = document.querySelector(`.widget-button[data-widget="${widgetName}"]`);
|
|
211
|
+
return !btn || btn.style.display !== 'none';
|
|
212
|
+
}
|
|
213
|
+
|
|
156
214
|
/**
|
|
157
215
|
* Toggle a widget open/closed.
|
|
158
216
|
*/
|
|
@@ -178,8 +236,12 @@ class WidgetManager {
|
|
|
178
236
|
const currentActive = this.getActive(side);
|
|
179
237
|
if (currentActive) {
|
|
180
238
|
this.deactivateContent(currentActive);
|
|
181
|
-
// Save the closed widget's state
|
|
182
|
-
|
|
239
|
+
// Save the closed widget's state — unless it is only lending its side out,
|
|
240
|
+
// in which case it has not been closed and should not be written down as
|
|
241
|
+
// closed. close() hands the side back when the borrower is done.
|
|
242
|
+
if (!this.persistent.has(currentActive)) {
|
|
243
|
+
this.saveState(currentActive, false);
|
|
244
|
+
}
|
|
183
245
|
}
|
|
184
246
|
|
|
185
247
|
this.setActive(side, widgetName);
|
|
@@ -233,6 +295,17 @@ class WidgetManager {
|
|
|
233
295
|
|
|
234
296
|
// Fire event
|
|
235
297
|
document.dispatchEvent(new CustomEvent('widget-closed', { detail: { widget: active, side } }));
|
|
298
|
+
|
|
299
|
+
// Give the side back to its resident, if it has one and the reader has not
|
|
300
|
+
// put it away themselves. Guarded against the resident being the very thing
|
|
301
|
+
// just closed — otherwise closing the TOC would reopen it. (It cannot
|
|
302
|
+
// recurse either way: the side is already empty by this point, so the open()
|
|
303
|
+
// below finds nothing to displace.)
|
|
304
|
+
const resident = this.residentOf(side);
|
|
305
|
+
if (resident && resident !== active &&
|
|
306
|
+
this.wantsToBeOpen(resident) && this.hasContent(resident)) {
|
|
307
|
+
this.open(resident);
|
|
308
|
+
}
|
|
236
309
|
}
|
|
237
310
|
|
|
238
311
|
/**
|
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.
|
|
5
|
+
"version": "0.93.0",
|
|
6
6
|
"description": "static site generator from MD/wikitext/YML",
|
|
7
7
|
"main": "lib/index.js",
|
|
8
8
|
"bin": {
|
|
@@ -44,13 +44,14 @@
|
|
|
44
44
|
"remark-directive": "^4.0.0",
|
|
45
45
|
"remark-gfm": "^4.0.1",
|
|
46
46
|
"remark-supersub": "^1.0.0",
|
|
47
|
-
"sharp": "^0.
|
|
47
|
+
"sharp": "^0.34.5",
|
|
48
48
|
"unist-util-visit": "^5.1.0",
|
|
49
49
|
"ws": "^8.19.0",
|
|
50
50
|
"yaml": "^2.1.3",
|
|
51
51
|
"yargs": "^17.7.2"
|
|
52
52
|
},
|
|
53
53
|
"devDependencies": {
|
|
54
|
+
"@tabler/icons": "^3.46.0",
|
|
54
55
|
"jest": "^29.6.2",
|
|
55
56
|
"node-static": "^0.7.11",
|
|
56
57
|
"nodemon": "^2.0.15"
|
|
@@ -79,7 +80,8 @@
|
|
|
79
80
|
],
|
|
80
81
|
"pnpm": {
|
|
81
82
|
"onlyBuiltDependencies": [
|
|
82
|
-
"esbuild"
|
|
83
|
+
"esbuild",
|
|
84
|
+
"sharp"
|
|
83
85
|
]
|
|
84
86
|
},
|
|
85
87
|
"publishConfig": {
|
package/src/dev.js
CHANGED
|
@@ -428,7 +428,7 @@ async function renderDocument(urlPath) {
|
|
|
428
428
|
}
|
|
429
429
|
|
|
430
430
|
// Inject breadcrumbs before the H1
|
|
431
|
-
const breadcrumbs = generateBreadcrumbs(dir, base, fileMeta);
|
|
431
|
+
const breadcrumbs = generateBreadcrumbs(dir, base, fileMeta, source);
|
|
432
432
|
if (breadcrumbs) {
|
|
433
433
|
body = breadcrumbs + body;
|
|
434
434
|
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { join } from "path";
|
|
2
|
+
import { mkdtemp, mkdir, writeFile, rm } from "fs/promises";
|
|
3
|
+
import { tmpdir } from "os";
|
|
4
|
+
import { generateBreadcrumbs } from "../breadcrumbs.js";
|
|
5
|
+
import { clearConfigCache } from "../folderConfig.js";
|
|
6
|
+
|
|
7
|
+
let source;
|
|
8
|
+
beforeEach(async () => {
|
|
9
|
+
source = await mkdtemp(join(tmpdir(), "ursa-breadcrumbs-"));
|
|
10
|
+
clearConfigCache();
|
|
11
|
+
});
|
|
12
|
+
afterEach(async () => {
|
|
13
|
+
await rm(source, { recursive: true, force: true });
|
|
14
|
+
clearConfigCache();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe("generateBreadcrumbs", () => {
|
|
18
|
+
it("names folder segments the way the menu does", async () => {
|
|
19
|
+
await mkdir(join(source, "campaigns", "bnw"), { recursive: true });
|
|
20
|
+
await writeFile(
|
|
21
|
+
join(source, "campaigns", "bnw", "index.md"),
|
|
22
|
+
"---\nmenu-label: 'BNW - Brave New World'\n---\n# Brave New World\n"
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
const html = generateBreadcrumbs("campaigns/bnw/", "quests", null, source);
|
|
26
|
+
|
|
27
|
+
expect(html).toContain(">BNW - Brave New World</a>");
|
|
28
|
+
expect(html).toContain('aria-current="page">Quests</span>');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("falls back to config.json when a folder has no index", async () => {
|
|
32
|
+
await mkdir(join(source, "campaigns", "hfr"), { recursive: true });
|
|
33
|
+
await writeFile(
|
|
34
|
+
join(source, "campaigns", "hfr", "config.json"),
|
|
35
|
+
JSON.stringify({ label: "HFR - Hyacinth: Fury Road" })
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
const html = generateBreadcrumbs("campaigns/hfr/", "sessions", null, source);
|
|
39
|
+
|
|
40
|
+
expect(html).toContain(">HFR - Hyacinth: Fury Road</a>");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("uses the folder label for the current crumb on a folder's index page", async () => {
|
|
44
|
+
await mkdir(join(source, "campaigns", "bnw"), { recursive: true });
|
|
45
|
+
await writeFile(
|
|
46
|
+
join(source, "campaigns", "bnw", "index.md"),
|
|
47
|
+
"---\nmenu-label: 'BNW - Brave New World'\n---\n# Brave New World\n"
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
const html = generateBreadcrumbs("campaigns/bnw/", "index", null, source);
|
|
51
|
+
|
|
52
|
+
expect(html).toContain('aria-current="page">BNW - Brave New World</span>');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("keeps the document's own frontmatter override for the last crumb", () => {
|
|
56
|
+
const withMenuLabel = generateBreadcrumbs("guides/", "setup", {
|
|
57
|
+
"menu-label": "Getting Started",
|
|
58
|
+
title: "Setup Guide",
|
|
59
|
+
});
|
|
60
|
+
expect(withMenuLabel).toContain('aria-current="page">Getting Started</span>');
|
|
61
|
+
|
|
62
|
+
// menu-label absent: title still wins, as it always has
|
|
63
|
+
const withTitle = generateBreadcrumbs("guides/", "setup", { title: "Setup Guide" });
|
|
64
|
+
expect(withTitle).toContain('aria-current="page">Setup Guide</span>');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("preserves interior capitalization without a source root", () => {
|
|
68
|
+
const html = generateBreadcrumbs("campaigns/SoL/", "index", null);
|
|
69
|
+
expect(html).toContain('aria-current="page">SoL</span>');
|
|
70
|
+
});
|
|
71
|
+
});
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { join } from "path";
|
|
2
|
+
import { mkdtemp, rm, mkdir, writeFile, readFile } from "fs/promises";
|
|
3
|
+
import { existsSync } from "fs";
|
|
4
|
+
import { tmpdir } from "os";
|
|
5
|
+
import {
|
|
6
|
+
enforceCacheVersion,
|
|
7
|
+
getUrsaDir,
|
|
8
|
+
loadHashCache,
|
|
9
|
+
saveHashCache,
|
|
10
|
+
} from "../contentHash.js";
|
|
11
|
+
|
|
12
|
+
let sourceDir;
|
|
13
|
+
beforeEach(async () => {
|
|
14
|
+
sourceDir = await mkdtemp(join(tmpdir(), "ursa-cachestamp-"));
|
|
15
|
+
});
|
|
16
|
+
afterEach(async () => {
|
|
17
|
+
await rm(sourceDir, { recursive: true, force: true });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
/** Write a populated `.ursa/` as an older build of ursa would have left it. */
|
|
21
|
+
async function seedCache(stampVersion) {
|
|
22
|
+
const ursaDir = getUrsaDir(sourceDir);
|
|
23
|
+
await mkdir(ursaDir, { recursive: true });
|
|
24
|
+
await saveHashCache(sourceDir, new Map([["/site/a.md", "abc123"]]));
|
|
25
|
+
if (stampVersion !== null) {
|
|
26
|
+
await writeFile(
|
|
27
|
+
join(ursaDir, "cache-stamp.json"),
|
|
28
|
+
JSON.stringify({ ursaVersion: stampVersion })
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe("enforceCacheVersion", () => {
|
|
34
|
+
it("stamps a first build without reporting a reset", async () => {
|
|
35
|
+
const result = await enforceCacheVersion(sourceDir, "1.0.0");
|
|
36
|
+
|
|
37
|
+
expect(result).toEqual({ reset: false, previous: null, version: "1.0.0" });
|
|
38
|
+
const stamp = JSON.parse(
|
|
39
|
+
await readFile(join(getUrsaDir(sourceDir), "cache-stamp.json"), "utf8")
|
|
40
|
+
);
|
|
41
|
+
expect(stamp.ursaVersion).toBe("1.0.0");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("keeps the cache when the stamp matches", async () => {
|
|
45
|
+
await seedCache("1.0.0");
|
|
46
|
+
|
|
47
|
+
const result = await enforceCacheVersion(sourceDir, "1.0.0");
|
|
48
|
+
|
|
49
|
+
expect(result.reset).toBe(false);
|
|
50
|
+
expect(await loadHashCache(sourceDir)).toEqual(
|
|
51
|
+
new Map([["/site/a.md", "abc123"]])
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("discards the cache when ursa has been upgraded", async () => {
|
|
56
|
+
await seedCache("1.0.0");
|
|
57
|
+
|
|
58
|
+
const result = await enforceCacheVersion(sourceDir, "1.1.0");
|
|
59
|
+
|
|
60
|
+
expect(result).toEqual({ reset: true, previous: "1.0.0", version: "1.1.0" });
|
|
61
|
+
expect(await loadHashCache(sourceDir)).toEqual(new Map());
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("discards the cache when ursa has been downgraded", async () => {
|
|
65
|
+
await seedCache("1.1.0");
|
|
66
|
+
|
|
67
|
+
const result = await enforceCacheVersion(sourceDir, "1.0.0");
|
|
68
|
+
|
|
69
|
+
expect(result.reset).toBe(true);
|
|
70
|
+
expect(await loadHashCache(sourceDir)).toEqual(new Map());
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("discards a cache left by a version that predates stamping", async () => {
|
|
74
|
+
await seedCache(null);
|
|
75
|
+
|
|
76
|
+
const result = await enforceCacheVersion(sourceDir, "1.0.0");
|
|
77
|
+
|
|
78
|
+
expect(result).toEqual({ reset: true, previous: null, version: "1.0.0" });
|
|
79
|
+
expect(await loadHashCache(sourceDir)).toEqual(new Map());
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("discards the cache when the stamp is unreadable", async () => {
|
|
83
|
+
await seedCache("1.0.0");
|
|
84
|
+
await writeFile(join(getUrsaDir(sourceDir), "cache-stamp.json"), "not json");
|
|
85
|
+
|
|
86
|
+
const result = await enforceCacheVersion(sourceDir, "1.0.0");
|
|
87
|
+
|
|
88
|
+
expect(result.reset).toBe(true);
|
|
89
|
+
expect(await loadHashCache(sourceDir)).toEqual(new Map());
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("leaves a stamp that the next run accepts", async () => {
|
|
93
|
+
await seedCache("1.0.0");
|
|
94
|
+
|
|
95
|
+
await enforceCacheVersion(sourceDir, "1.1.0");
|
|
96
|
+
await saveHashCache(sourceDir, new Map([["/site/a.md", "def456"]]));
|
|
97
|
+
const second = await enforceCacheVersion(sourceDir, "1.1.0");
|
|
98
|
+
|
|
99
|
+
expect(second.reset).toBe(false);
|
|
100
|
+
expect(await loadHashCache(sourceDir)).toEqual(
|
|
101
|
+
new Map([["/site/a.md", "def456"]])
|
|
102
|
+
);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("removes every cache file in .ursa, not just the hashes", async () => {
|
|
106
|
+
await seedCache("1.0.0");
|
|
107
|
+
const navCache = join(getUrsaDir(sourceDir), "nav-cache.json");
|
|
108
|
+
await writeFile(navCache, JSON.stringify({ stale: true }));
|
|
109
|
+
|
|
110
|
+
await enforceCacheVersion(sourceDir, "1.1.0");
|
|
111
|
+
|
|
112
|
+
expect(existsSync(navCache)).toBe(false);
|
|
113
|
+
});
|
|
114
|
+
});
|
package/src/helper/automenu.js
CHANGED
|
@@ -3,8 +3,15 @@ import { isHiddenOrSystemPath } from "./hiddenPaths.js";
|
|
|
3
3
|
import { extname, basename, join, dirname } from "path";
|
|
4
4
|
import { existsSync, readFileSync } from "fs";
|
|
5
5
|
import { getFolderConfig, isFolderHidden, getRootConfig } from "./folderConfig.js";
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
6
|
+
import { isMetadataOnly } from "./metadataExtractor.js";
|
|
7
|
+
import {
|
|
8
|
+
INDEX_EXTENSIONS,
|
|
9
|
+
toDisplayName,
|
|
10
|
+
getMenuLabelFromFile,
|
|
11
|
+
getMenuSortAsFromFile,
|
|
12
|
+
getFolderLabel,
|
|
13
|
+
getFolderSortKey,
|
|
14
|
+
} from "./menuLabels.js";
|
|
8
15
|
|
|
9
16
|
// Icon extensions to check for custom icons
|
|
10
17
|
const ICON_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.ico'];
|
|
@@ -14,93 +21,6 @@ const FOLDER_ICON = '📁';
|
|
|
14
21
|
const DOCUMENT_ICON = '📄';
|
|
15
22
|
const HOME_ICON = '🏠';
|
|
16
23
|
|
|
17
|
-
// Index file extensions to check for folder links
|
|
18
|
-
const INDEX_EXTENSIONS = ['.md', '.mdx', '.txt', '.yml', '.yaml'];
|
|
19
|
-
|
|
20
|
-
// Convert filename to display name (e.g., "foo-bar" -> "Foo Bar")
|
|
21
|
-
function toDisplayName(filename) {
|
|
22
|
-
return filename
|
|
23
|
-
.replace(/[-_]/g, ' ') // Replace dashes and underscores with spaces
|
|
24
|
-
.replace(/\b\w/g, c => c.toUpperCase()); // Capitalize first letter of each word
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* Get the menu label from a file's frontmatter
|
|
29
|
-
* @param {string} filePath - Path to the markdown file
|
|
30
|
-
* @returns {string|null} The menu-label value (with HTML stripped), or null if not found
|
|
31
|
-
*/
|
|
32
|
-
function getMenuLabelFromFile(filePath) {
|
|
33
|
-
try {
|
|
34
|
-
if (!existsSync(filePath)) return null;
|
|
35
|
-
const content = readFileSync(filePath, 'utf8');
|
|
36
|
-
const metadata = extractMetadata(content);
|
|
37
|
-
if (metadata && metadata['menu-label']) {
|
|
38
|
-
return stripHtml(String(metadata['menu-label']));
|
|
39
|
-
}
|
|
40
|
-
} catch (e) {
|
|
41
|
-
// Ignore read errors
|
|
42
|
-
}
|
|
43
|
-
return null;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Get the menu-sort-as value from a file's frontmatter
|
|
48
|
-
* @param {string} filePath - Path to the markdown file
|
|
49
|
-
* @returns {string|null} The menu-sort-as value (with HTML stripped), or null if not found
|
|
50
|
-
*/
|
|
51
|
-
function getMenuSortAsFromFile(filePath) {
|
|
52
|
-
try {
|
|
53
|
-
if (!existsSync(filePath)) return null;
|
|
54
|
-
const content = readFileSync(filePath, 'utf8');
|
|
55
|
-
const metadata = extractMetadata(content);
|
|
56
|
-
if (metadata && metadata['menu-sort-as']) {
|
|
57
|
-
return stripHtml(String(metadata['menu-sort-as']));
|
|
58
|
-
}
|
|
59
|
-
} catch (e) {
|
|
60
|
-
// Ignore read errors
|
|
61
|
-
}
|
|
62
|
-
return null;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* Get the menu label for a folder from its index.md frontmatter
|
|
67
|
-
* Falls back to config.json label (deprecated), then display name
|
|
68
|
-
* @param {string} dirPath - Path to the folder
|
|
69
|
-
* @param {object|null} folderConfig - The folder's config.json if any
|
|
70
|
-
* @param {string} baseName - The folder's base name
|
|
71
|
-
* @returns {string} The label to display
|
|
72
|
-
*/
|
|
73
|
-
function getFolderLabel(dirPath, folderConfig, baseName) {
|
|
74
|
-
// First, check index.md for menu-label (preferred method)
|
|
75
|
-
for (const ext of INDEX_EXTENSIONS) {
|
|
76
|
-
const indexPath = join(dirPath, `index${ext}`);
|
|
77
|
-
const label = getMenuLabelFromFile(indexPath);
|
|
78
|
-
if (label) return label;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
// Fall back to config.json label (deprecated)
|
|
82
|
-
if (folderConfig?.label) {
|
|
83
|
-
return folderConfig.label;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
// Default to display name from folder name
|
|
87
|
-
return toDisplayName(baseName);
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/**
|
|
91
|
-
* Get the sort key for a folder from its index.md frontmatter
|
|
92
|
-
* @param {string} dirPath - Path to the folder
|
|
93
|
-
* @returns {string|null} The menu-sort-as value, or null if not found
|
|
94
|
-
*/
|
|
95
|
-
function getFolderSortKey(dirPath) {
|
|
96
|
-
for (const ext of INDEX_EXTENSIONS) {
|
|
97
|
-
const indexPath = join(dirPath, `index${ext}`);
|
|
98
|
-
const sortKey = getMenuSortAsFromFile(indexPath);
|
|
99
|
-
if (sortKey) return sortKey;
|
|
100
|
-
}
|
|
101
|
-
return null;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
24
|
/**
|
|
105
25
|
* Check if a file is an index file
|
|
106
26
|
* @param {string} baseName - The file's base name (without extension)
|
|
@@ -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,7 @@ 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
6
|
|
|
7
7
|
let tempDir;
|
|
8
8
|
let source;
|
|
@@ -93,3 +93,70 @@ describe("generateAutoIndices with empty source folders", () => {
|
|
|
93
93
|
expect(rootIndex).toContain('<a href="docs/index.html">');
|
|
94
94
|
});
|
|
95
95
|
});
|
|
96
|
+
|
|
97
|
+
describe("auto-index naming matches the automenu", () => {
|
|
98
|
+
it("uses menu-label from a folder's index frontmatter, and config.json when there is no index", async () => {
|
|
99
|
+
// bnw has a real index.md carrying the label; hfr has no index at all, so
|
|
100
|
+
// it falls back to config.json — the same two-step the automenu uses.
|
|
101
|
+
await mkdir(join(source, "bnw"));
|
|
102
|
+
await writeFile(
|
|
103
|
+
join(source, "bnw", "index.md"),
|
|
104
|
+
"---\nmenu-label: 'BNW - Brave New World'\n---\n"
|
|
105
|
+
);
|
|
106
|
+
await writeFile(join(source, "bnw", "quests.md"), "# Quests\n");
|
|
107
|
+
await mkdir(join(source, "hfr"));
|
|
108
|
+
await writeFile(
|
|
109
|
+
join(source, "hfr", "config.json"),
|
|
110
|
+
JSON.stringify({ label: "HFR - Hyacinth: Fury Road" })
|
|
111
|
+
);
|
|
112
|
+
await writeFile(join(source, "hfr", "hfr.md"), "# Hyacinth\n");
|
|
113
|
+
|
|
114
|
+
const html = await generateAutoIndexHtmlFromSource(source, 1);
|
|
115
|
+
|
|
116
|
+
expect(html).toContain('<a href="bnw/index.html">BNW - Brave New World</a>');
|
|
117
|
+
expect(html).toContain('<a href="hfr/index.html">HFR - Hyacinth: Fury Road</a>');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("uses menu-label on individual documents and menu-sort-as for ordering", async () => {
|
|
121
|
+
await writeFile(
|
|
122
|
+
join(source, "zebra.md"),
|
|
123
|
+
"---\nmenu-label: 'ZED - Zebra'\nmenu-sort-as: 'aardvark'\n---\n# Zebra\n"
|
|
124
|
+
);
|
|
125
|
+
await writeFile(join(source, "middle.md"), "# Middle\n");
|
|
126
|
+
|
|
127
|
+
const html = await generateAutoIndexHtmlFromSource(source, 1);
|
|
128
|
+
|
|
129
|
+
expect(html).toContain('<a href="zebra.html">ZED - Zebra</a>');
|
|
130
|
+
// Sorted by menu-sort-as ("aardvark"), not by filename ("zebra")
|
|
131
|
+
expect(html.indexOf("ZED - Zebra")).toBeLessThan(html.indexOf("Middle"));
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("preserves interior capitalization instead of title-casing it away", async () => {
|
|
135
|
+
await mkdir(join(source, "SoL"));
|
|
136
|
+
await writeFile(join(source, "SoL", "notes.md"), "# Notes\n");
|
|
137
|
+
|
|
138
|
+
const html = await generateAutoIndexHtmlFromSource(source, 1);
|
|
139
|
+
|
|
140
|
+
expect(html).toContain('<a href="SoL/index.html">SoL</a>');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("labels generated index pages with the folder's menu-label", async () => {
|
|
144
|
+
// No index.md content, so this folder gets an auto-generated index.html;
|
|
145
|
+
// its <h1> should use the label rather than the raw folder name.
|
|
146
|
+
await mkdir(join(source, "bnw"));
|
|
147
|
+
await writeFile(
|
|
148
|
+
join(source, "bnw", "config.json"),
|
|
149
|
+
JSON.stringify({ label: "BNW - Brave New World" })
|
|
150
|
+
);
|
|
151
|
+
await writeFile(join(source, "bnw", "quests.md"), "# Quests\n");
|
|
152
|
+
await mkdir(join(output, "bnw"));
|
|
153
|
+
await writeFile(join(output, "bnw", "quests.html"), "<html><body>Quests</body></html>");
|
|
154
|
+
|
|
155
|
+
await runAutoIndices([join(source, "bnw")], [join(source, "bnw", "quests.md")], makeProgress());
|
|
156
|
+
|
|
157
|
+
const bnwIndex = await readFile(join(output, "bnw", "index.html"), "utf8");
|
|
158
|
+
expect(bnwIndex).toContain("<h1>BNW - Brave New World</h1>");
|
|
159
|
+
const rootIndex = await readFile(join(output, "index.html"), "utf8");
|
|
160
|
+
expect(rootIndex).toContain('<a href="bnw/index.html">BNW - Brave New World</a>');
|
|
161
|
+
});
|
|
162
|
+
});
|