@kenjura/ursa 0.96.0 → 0.98.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 +44 -0
- package/README.md +144 -16
- package/bin/ursa.js +14 -1
- package/meta/templates/default-template/default.css +144 -0
- package/meta/templates/default-template/menu.js +18 -1
- package/meta/templates/default-template/search.js +11 -0
- package/meta/templates/default-template/sectionify.js +17 -9
- package/meta/templates/default-template/widgets.js +4 -0
- package/package.json +1 -2
- package/src/dev.js +13 -23
- package/src/helper/__test__/contentHash.test.js +16 -6
- package/src/helper/__test__/inlineMenu.test.js +142 -0
- package/src/helper/assetBundler.js +93 -19
- package/src/helper/automenu.js +39 -13
- package/src/helper/build/__test__/autoIndex.test.js +2 -132
- package/src/helper/build/__test__/graph.test.js +259 -3
- package/src/helper/build/__test__/pass.test.js +664 -0
- package/src/helper/build/autoIndex.js +6 -371
- package/src/helper/build/excludeFilter.js +1 -2
- package/src/helper/build/footer.js +27 -14
- package/src/helper/build/graph.js +575 -152
- package/src/helper/build/index.js +0 -2
- package/src/helper/build/metadata.js +19 -5
- package/src/helper/build/pass.js +497 -0
- package/src/helper/build/precedence.js +174 -0
- package/src/helper/build/site.js +1392 -0
- package/src/helper/build/templates.js +1 -2
- package/src/helper/build/tracedFs.js +247 -0
- package/src/helper/contentHash.js +0 -78
- package/src/helper/customMenu.js +27 -4
- package/src/helper/fileRenderer.js +119 -111
- package/src/helper/findScriptJs.js +1 -1
- package/src/helper/findStyleCss.js +1 -1
- package/src/helper/folderConfig.js +7 -18
- package/src/helper/fullTextIndex.js +41 -29
- package/src/helper/imageProcessor.js +45 -0
- package/src/helper/inlineMenu.js +275 -0
- package/src/helper/linkValidator.js +118 -127
- package/src/helper/mdxRenderer.js +27 -5
- package/src/helper/menuLabels.js +30 -5
- package/src/helper/whitelistFilter.js +1 -2
- package/src/jobs/generate.js +67 -1829
- package/src/serve.js +317 -697
- package/src/helper/__test__/dependencyTracker.test.js +0 -157
- package/src/helper/build/cacheBust.js +0 -141
- package/src/helper/build/navCache.js +0 -145
- package/src/helper/build/watchCache.js +0 -33
- package/src/helper/dependencyTracker.js +0 -384
|
@@ -2,12 +2,22 @@ import { join } from "path";
|
|
|
2
2
|
import { mkdtemp, rm, mkdir, writeFile, readFile } from "fs/promises";
|
|
3
3
|
import { existsSync } from "fs";
|
|
4
4
|
import { tmpdir } from "os";
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
5
|
+
import { enforceCacheVersion, getUrsaDir } from "../contentHash.js";
|
|
6
|
+
|
|
7
|
+
// The cache `.ursa/` holds is the build graph. These tests only need a file
|
|
8
|
+
// that must survive a matching stamp and vanish on a mismatch.
|
|
9
|
+
const CACHE_FILE = "graph.json";
|
|
10
|
+
async function saveHashCache(dir, map) {
|
|
11
|
+
await mkdir(getUrsaDir(dir), { recursive: true });
|
|
12
|
+
await writeFile(join(getUrsaDir(dir), CACHE_FILE), JSON.stringify([...map]));
|
|
13
|
+
}
|
|
14
|
+
async function loadHashCache(dir) {
|
|
15
|
+
try {
|
|
16
|
+
return new Map(JSON.parse(await readFile(join(getUrsaDir(dir), CACHE_FILE), "utf8")));
|
|
17
|
+
} catch {
|
|
18
|
+
return new Map();
|
|
19
|
+
}
|
|
20
|
+
}
|
|
11
21
|
|
|
12
22
|
let sourceDir;
|
|
13
23
|
beforeEach(async () => {
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { join } from "path";
|
|
2
|
+
import { mkdtemp, mkdir, writeFile, rm } from "fs/promises";
|
|
3
|
+
import { tmpdir } from "os";
|
|
4
|
+
import {
|
|
5
|
+
namedMenuOptions,
|
|
6
|
+
findNamedMenu,
|
|
7
|
+
collectMenuAnchorIds,
|
|
8
|
+
prepareMdxMenuAnchors,
|
|
9
|
+
resolveMenuAnchors,
|
|
10
|
+
renderInlineMenuHtml,
|
|
11
|
+
menuNotFoundComment,
|
|
12
|
+
leadingMenusEnd,
|
|
13
|
+
} from "../inlineMenu.js";
|
|
14
|
+
import { isMenuFile, findCustomMenu } from "../customMenu.js";
|
|
15
|
+
|
|
16
|
+
describe("isMenuFile", () => {
|
|
17
|
+
it("matches the folder menu and named menus, not documents", () => {
|
|
18
|
+
for (const name of ["menu.md", "_menu.md", "menu.txt", "menu-2.md", "menu-classes.md", "_menu-x.txt", "Menu-Classes.md"]) {
|
|
19
|
+
expect(isMenuFile(name)).toBe(true);
|
|
20
|
+
}
|
|
21
|
+
for (const name of ["menus.md", "menu.mdx", "my-menu.md", "menu-.md", "menu_x.md", "index.md", "menu"]) {
|
|
22
|
+
expect(isMenuFile(name)).toBe(false);
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe("namedMenuOptions", () => {
|
|
28
|
+
it("defaults appearance to horizontal and reports an invalid value", () => {
|
|
29
|
+
expect(namedMenuOptions({ id: "classes" })).toEqual({ id: "classes", appearance: "horizontal", appearanceInvalid: null });
|
|
30
|
+
expect(namedMenuOptions({ id: "x", appearance: "Vertical" }).appearance).toBe("vertical");
|
|
31
|
+
expect(namedMenuOptions({ id: "x", appearance: "sideways" })).toMatchObject({ appearance: "horizontal", appearanceInvalid: "sideways" });
|
|
32
|
+
expect(namedMenuOptions({}).id).toBeNull();
|
|
33
|
+
expect(namedMenuOptions({ id: "" }).id).toBeNull();
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("anchors", () => {
|
|
38
|
+
it("collects ids in both forms, once each", () => {
|
|
39
|
+
const html = '<p>{menu:a}</p><div data-ursa-menu="b"></div><p>x {menu:a} y</p>';
|
|
40
|
+
expect(collectMenuAnchorIds(html)).toEqual(["b", "a"]);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("rewrites MDX anchors alone on a line into the element form", () => {
|
|
44
|
+
const src = "---\nx: 1\n---\n\n{menu:classes}\n\nText with {menu:inline} stays.\n {menu:indented} \n";
|
|
45
|
+
const out = prepareMdxMenuAnchors(src);
|
|
46
|
+
expect(out).toContain('<div data-ursa-menu="classes"></div>');
|
|
47
|
+
expect(out).toContain('<div data-ursa-menu="indented"></div>');
|
|
48
|
+
expect(out).toContain("Text with {menu:inline} stays.");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("replaces a paragraph that is only the anchor", () => {
|
|
52
|
+
const out = resolveMenuAnchors("<h1>T</h1>\n<p>{menu:classes}</p>\n<p>Body.</p>", (id) => `<nav>${id}</nav>`);
|
|
53
|
+
expect(out).toBe("<h1>T</h1>\n<nav>classes</nav>\n<p>Body.</p>");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("splits a paragraph with text around the anchor so the nav is not inside a <p>", () => {
|
|
57
|
+
const out = resolveMenuAnchors("<p>Before {menu:x} after</p>", (id) => `<nav>${id}</nav>`);
|
|
58
|
+
expect(out).toBe("<p>Before</p>\n<nav>x</nav><p>after</p>\n");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("leaves anchors quoted in code alone", () => {
|
|
62
|
+
const html = "<p>Write <code>{menu:x}</code> on its own line.</p>\n<pre><code>{menu:y}</code></pre>";
|
|
63
|
+
expect(resolveMenuAnchors(html, () => "NO")).toBe(html);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("replaces the element form anywhere", () => {
|
|
67
|
+
const out = resolveMenuAnchors('<div data-ursa-menu="c"></div><div data-ursa-menu="d"/>', (id) => `[${id}]`);
|
|
68
|
+
expect(out).toBe("[c][d]");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("is a no-op for bodies without anchors", () => {
|
|
72
|
+
const html = "<p>Nothing here { menu } either</p>";
|
|
73
|
+
expect(resolveMenuAnchors(html, () => "X")).toBe(html);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("finds where leading menus end", () => {
|
|
77
|
+
const nav = '<nav class="ursa-menu ursa-menu-horizontal" data-menu-id="a"><ul></ul></nav>';
|
|
78
|
+
expect(leadingMenusEnd(`${nav}\n<p>x</p>`)).toBe(nav.length);
|
|
79
|
+
expect(leadingMenusEnd(`\n${nav}${menuNotFoundComment("b")}<h1>T</h1>`)).toBe(1 + nav.length + menuNotFoundComment("b").length);
|
|
80
|
+
expect(leadingMenusEnd("<h1>T</h1>")).toBe(0);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe("renderInlineMenuHtml", () => {
|
|
85
|
+
const data = [
|
|
86
|
+
{ label: "Arcanist", href: "/character/classes/arcanist.html", children: [] },
|
|
87
|
+
{ label: "Fighter & Co", href: "/character/classes/fighter.html", children: [] },
|
|
88
|
+
{
|
|
89
|
+
label: "More",
|
|
90
|
+
href: null,
|
|
91
|
+
children: [{ label: "Witch", href: "/character/classes/witch.html", children: [] }],
|
|
92
|
+
},
|
|
93
|
+
];
|
|
94
|
+
|
|
95
|
+
it("renders a horizontal nav by default, escaping labels", () => {
|
|
96
|
+
const html = renderInlineMenuHtml(data, { id: "classes" });
|
|
97
|
+
expect(html).toMatch(/^<nav class="ursa-menu ursa-menu-horizontal" data-menu-id="classes"/);
|
|
98
|
+
expect(html).toContain("Fighter & Co");
|
|
99
|
+
expect(html).toContain('<ul class="ursa-menu-level" data-depth="1">');
|
|
100
|
+
expect(html).toContain('<li class="ursa-menu-item ursa-menu-has-children"><span>More</span>');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("marks the current page and its ancestors", () => {
|
|
104
|
+
const html = renderInlineMenuHtml(data, { id: "classes", appearance: "vertical", currentUrl: "/character/classes/witch.html" });
|
|
105
|
+
expect(html).toContain("ursa-menu-vertical");
|
|
106
|
+
expect(html).toContain('<li class="ursa-menu-item ursa-menu-current"><a href="/character/classes/witch.html" aria-current="page">Witch</a>');
|
|
107
|
+
expect(html).toContain('<li class="ursa-menu-item ursa-menu-has-children ursa-menu-active"><span>More</span>');
|
|
108
|
+
expect(html).not.toContain('ursa-menu-current"><a href="/character/classes/arcanist.html"');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("treats index.html, a trailing slash and no extension as the same page", () => {
|
|
112
|
+
const items = [{ label: "Classes", href: "/character/classes/index.html", children: [] }];
|
|
113
|
+
for (const url of ["/character/classes/", "/character/classes", "/character/classes/index.html"]) {
|
|
114
|
+
expect(renderInlineMenuHtml(items, { id: "x", currentUrl: url })).toContain("ursa-menu-current");
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe("findNamedMenu / findCustomMenu", () => {
|
|
120
|
+
let root;
|
|
121
|
+
beforeEach(async () => {
|
|
122
|
+
root = await mkdtemp(join(tmpdir(), "ursa-inline-menu-"));
|
|
123
|
+
await mkdir(join(root, "a/b"), { recursive: true });
|
|
124
|
+
await writeFile(join(root, "menu.md"), "---\nauto-generate-menu: true\n---\n");
|
|
125
|
+
await writeFile(join(root, "menu-classes.md"), "---\nid: classes\n---\n- [Root](./index.md)\n");
|
|
126
|
+
await writeFile(join(root, "a/menu-classes.md"), "---\nid: classes\nappearance: vertical\n---\n- [Deep](./x.md)\n");
|
|
127
|
+
await writeFile(join(root, "a/b/menu.md"), "---\nid: named-main\n---\n- [Hidden](./y.md)\n");
|
|
128
|
+
});
|
|
129
|
+
afterEach(() => rm(root, { recursive: true, force: true }));
|
|
130
|
+
|
|
131
|
+
it("nearest file with the id wins", () => {
|
|
132
|
+
expect(findNamedMenu(join(root, "a/b"), root, "classes").menuDir).toBe(join(root, "a"));
|
|
133
|
+
expect(findNamedMenu(root, root, "classes").menuDir).toBe(root);
|
|
134
|
+
expect(findNamedMenu(join(root, "a/b"), root, "named-main").path).toBe(join(root, "a/b/menu.md"));
|
|
135
|
+
expect(findNamedMenu(join(root, "a/b"), root, "nope")).toBeNull();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("a menu.md with an id is not the folder's nav menu", () => {
|
|
139
|
+
const info = findCustomMenu(join(root, "a/b"), root);
|
|
140
|
+
expect(info.menuDir).toBe(root);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
@@ -13,8 +13,8 @@
|
|
|
13
13
|
|
|
14
14
|
import * as esbuild from "esbuild";
|
|
15
15
|
import { join, dirname, basename, relative, resolve } from "path";
|
|
16
|
-
import {
|
|
17
|
-
import { existsSync } from "
|
|
16
|
+
import { writeFile, mkdir } from "fs/promises";
|
|
17
|
+
import { readFile, existsSync } from "./build/tracedFs.js";
|
|
18
18
|
import { outputFile } from "fs-extra";
|
|
19
19
|
|
|
20
20
|
// Cache for meta bundles so we don't rebuild them per-document
|
|
@@ -68,10 +68,13 @@ export function parseTemplateAssets(templateHtml) {
|
|
|
68
68
|
* @param {string} templateHtml - Original template HTML
|
|
69
69
|
* @param {string} templateName - Template name (used for bundle filename)
|
|
70
70
|
* @param {{ cssFiles: string[], jsFiles: string[], cdnCss: string[], cdnJs: string[] }} assets - Parsed assets
|
|
71
|
+
* @param {{cssUrl?: string, jsUrl?: string}} [urls] - Bundle URLs to emit (default: /public/<name>.bundle.*)
|
|
71
72
|
* @returns {string} Rewritten template HTML
|
|
72
73
|
*/
|
|
73
|
-
export function rewriteTemplateWithBundles(templateHtml, templateName, assets) {
|
|
74
|
+
export function rewriteTemplateWithBundles(templateHtml, templateName, assets, urls = {}) {
|
|
74
75
|
let html = templateHtml;
|
|
76
|
+
const cssUrl = urls.cssUrl ?? `/public/${templateName}.bundle.css`;
|
|
77
|
+
const jsUrl = urls.jsUrl ?? `/public/${templateName}.bundle.js`;
|
|
75
78
|
|
|
76
79
|
// Replace individual CSS <link> tags with a single bundle reference
|
|
77
80
|
if (assets.cssFiles.length > 0) {
|
|
@@ -86,7 +89,7 @@ export function rewriteTemplateWithBundles(templateHtml, templateName, assets) {
|
|
|
86
89
|
html = html.replace(pattern, "\n");
|
|
87
90
|
}
|
|
88
91
|
// Insert single bundle link where the first CSS link was (in <head>)
|
|
89
|
-
const bundleCssTag = ` <link rel="stylesheet" href="
|
|
92
|
+
const bundleCssTag = ` <link rel="stylesheet" href="${cssUrl}" />`;
|
|
90
93
|
// Insert after the last CDN CSS or at the position of the first removed tag
|
|
91
94
|
// Best heuristic: insert right before ${styleLink} or before </head>
|
|
92
95
|
if (html.includes("${styleLink}")) {
|
|
@@ -107,7 +110,7 @@ export function rewriteTemplateWithBundles(templateHtml, templateName, assets) {
|
|
|
107
110
|
html = html.replace(pattern, "\n");
|
|
108
111
|
}
|
|
109
112
|
// Insert single bundle script before ${customScript} or before </body>
|
|
110
|
-
const bundleJsTag = ` <script src="
|
|
113
|
+
const bundleJsTag = ` <script src="${jsUrl}"></script>`;
|
|
111
114
|
if (html.includes("${customScript}")) {
|
|
112
115
|
html = html.replace("${customScript}", bundleJsTag + "\n ${customScript}");
|
|
113
116
|
} else {
|
|
@@ -192,6 +195,19 @@ function rebaseCssUrls(css, cssFileDir, sourceDir) {
|
|
|
192
195
|
*/
|
|
193
196
|
export async function bundleCss(filePaths, outputPath, { minify = true, rebaseUrls = false, sourceDir = "" } = {}) {
|
|
194
197
|
if (filePaths.length === 0) return;
|
|
198
|
+
const code = await bundleCssContent(filePaths, { minify, rebaseUrls, sourceDir });
|
|
199
|
+
await mkdir(dirname(outputPath), { recursive: true });
|
|
200
|
+
await writeFile(outputPath, code);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Bundle CSS files and return the result instead of writing it.
|
|
205
|
+
* @param {string[]} filePaths - Absolute paths to CSS files to bundle
|
|
206
|
+
* @param {{ minify?: boolean, rebaseUrls?: boolean, sourceDir?: string }} options
|
|
207
|
+
* @returns {Promise<string>} Bundled (and, when possible, minified) CSS
|
|
208
|
+
*/
|
|
209
|
+
export async function bundleCssContent(filePaths, { minify = true, rebaseUrls = false, sourceDir = "" } = {}) {
|
|
210
|
+
if (filePaths.length === 0) return "";
|
|
195
211
|
|
|
196
212
|
const allImports = [];
|
|
197
213
|
const allRules = [];
|
|
@@ -224,17 +240,14 @@ export async function bundleCss(filePaths, outputPath, { minify = true, rebaseUr
|
|
|
224
240
|
loader: "css",
|
|
225
241
|
minify: true,
|
|
226
242
|
});
|
|
227
|
-
|
|
228
|
-
await writeFile(outputPath, result.code);
|
|
229
|
-
return;
|
|
243
|
+
return result.code;
|
|
230
244
|
} catch (e) {
|
|
231
245
|
console.warn(`⚠️ CSS minification failed, using unminified bundle: ${e.message}`);
|
|
232
246
|
}
|
|
233
247
|
}
|
|
234
248
|
|
|
235
|
-
// Fallback:
|
|
236
|
-
|
|
237
|
-
await writeFile(outputPath, combined);
|
|
249
|
+
// Fallback: raw concatenated CSS
|
|
250
|
+
return combined;
|
|
238
251
|
}
|
|
239
252
|
|
|
240
253
|
/**
|
|
@@ -251,6 +264,22 @@ export async function bundleCss(filePaths, outputPath, { minify = true, rebaseUr
|
|
|
251
264
|
*/
|
|
252
265
|
export async function bundleJs(filePaths, outputPath, { minify = true, minifySyntax = true } = {}) {
|
|
253
266
|
if (filePaths.length === 0) return { success: false };
|
|
267
|
+
const { success, code } = await bundleJsContent(filePaths, { minify, minifySyntax });
|
|
268
|
+
if (!success) return { success: false };
|
|
269
|
+
await mkdir(dirname(outputPath), { recursive: true });
|
|
270
|
+
await writeFile(outputPath, code);
|
|
271
|
+
return { success: true };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Bundle JS files and return the result instead of writing it.
|
|
276
|
+
* @param {string[]} filePaths - Absolute paths to JS files to bundle
|
|
277
|
+
* @param {{ minify?: boolean, minifySyntax?: boolean }} options
|
|
278
|
+
* @returns {Promise<{ success: boolean, code: string }>} success is false when the
|
|
279
|
+
* concatenation has a syntax error (callers keep individual tags)
|
|
280
|
+
*/
|
|
281
|
+
export async function bundleJsContent(filePaths, { minify = true, minifySyntax = true } = {}) {
|
|
282
|
+
if (filePaths.length === 0) return { success: false, code: "" };
|
|
254
283
|
|
|
255
284
|
// Concatenate all JS files with separators
|
|
256
285
|
const contents = [];
|
|
@@ -268,9 +297,7 @@ export async function bundleJs(filePaths, outputPath, { minify = true, minifySyn
|
|
|
268
297
|
minify: true,
|
|
269
298
|
minifySyntax,
|
|
270
299
|
});
|
|
271
|
-
|
|
272
|
-
await writeFile(outputPath, result.code);
|
|
273
|
-
return { success: true };
|
|
300
|
+
return { success: true, code: result.code };
|
|
274
301
|
} catch (e) {
|
|
275
302
|
// If minification fails (e.g., non-standard syntax), fall back to raw concatenation
|
|
276
303
|
console.warn(`⚠️ JS minification failed, trying unminified bundle: ${e.message}`);
|
|
@@ -284,13 +311,60 @@ export async function bundleJs(filePaths, outputPath, { minify = true, minifySyn
|
|
|
284
311
|
await esbuild.transform(combined, { loader: "js" });
|
|
285
312
|
} catch (e) {
|
|
286
313
|
console.warn(`⚠️ JS bundle has syntax errors, keeping individual script tags: ${e.message}`);
|
|
287
|
-
return { success: false };
|
|
314
|
+
return { success: false, code: "" };
|
|
288
315
|
}
|
|
289
316
|
|
|
290
|
-
//
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
317
|
+
// Raw concatenated code (syntax-valid but unminified)
|
|
318
|
+
return { success: true, code: combined };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Where a template's `/public/<rel>` reference lives in the meta directory:
|
|
323
|
+
* the template's own folder first, then `meta/shared`, then (legacy) the meta
|
|
324
|
+
* root. Every probe is a recorded lookup, so moving an asset between the two
|
|
325
|
+
* folders is observed. Returns null when nothing exists.
|
|
326
|
+
*/
|
|
327
|
+
export function resolveMetaAssetPath(publicPath, templateDir, metaDir) {
|
|
328
|
+
const relativePath = publicPath.replace(/^\/public\//, "");
|
|
329
|
+
for (const candidate of [
|
|
330
|
+
resolve(templateDir, relativePath),
|
|
331
|
+
resolve(metaDir, "shared", relativePath),
|
|
332
|
+
resolve(metaDir, relativePath),
|
|
333
|
+
]) {
|
|
334
|
+
if (existsSync(candidate)) return candidate;
|
|
335
|
+
}
|
|
336
|
+
return null;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Append `?v=<hash>` to every `url()` in a stylesheet whose target `lookup`
|
|
341
|
+
* knows. URLs with a query string, data: URIs, fragments and anything the
|
|
342
|
+
* lookup does not recognise are left alone. Cache-busting by content hash
|
|
343
|
+
* keeps an unchanged asset's URL stable, so pages are not rewritten for it.
|
|
344
|
+
* @param {string} css
|
|
345
|
+
* @param {(url: string) => string|null} lookup - site-absolute URL → content hash
|
|
346
|
+
*/
|
|
347
|
+
export function versionCssUrls(css, lookup) {
|
|
348
|
+
return css.replace(/url\(\s*(['"]?)(?!data:)([^'"\)]+?)\1\s*\)/gi, (match, quote, url) => {
|
|
349
|
+
if (url.includes("?") || url.startsWith("#")) return match;
|
|
350
|
+
const hash = lookup(url);
|
|
351
|
+
if (!hash) return match;
|
|
352
|
+
return `url(${quote}${url}?v=${hash}${quote})`;
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Rewrite literal `fetch('/public/x.json')` calls in template JavaScript so
|
|
358
|
+
* the request carries the page's build id: `data-build` on <body>, set once
|
|
359
|
+
* per serve session and once per generate run. The bundle itself stays a pure
|
|
360
|
+
* function of the meta files — baking a JSON file's content hash into it
|
|
361
|
+
* would rewrite the bundle, and so every page, whenever the menu changed.
|
|
362
|
+
*/
|
|
363
|
+
export function rewriteJsonFetches(js) {
|
|
364
|
+
return js.replace(
|
|
365
|
+
/fetch\((['"])([^'"\)]+\.json)\1(?!\s*\+)/g,
|
|
366
|
+
(m, q, url) => `fetch(${q}${url}?v=${q}+(document.body&&document.body.dataset.build||'')`
|
|
367
|
+
);
|
|
294
368
|
}
|
|
295
369
|
|
|
296
370
|
/**
|
package/src/helper/automenu.js
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
|
-
import dirTree from "directory-tree";
|
|
2
1
|
import { isHiddenOrSystemPath } from "./hiddenPaths.js";
|
|
3
2
|
import { extname, basename, join, dirname } from "path";
|
|
4
|
-
import { existsSync, readFileSync } from "
|
|
3
|
+
import { existsSync, readFileSync, readdirSync, isIgnoredDirEntry } from "./build/tracedFs.js";
|
|
5
4
|
import { getFolderConfig, isFolderHidden, getRootConfig } from "./folderConfig.js";
|
|
6
|
-
import {
|
|
5
|
+
import { isMenuFile } from "./customMenu.js";
|
|
7
6
|
import {
|
|
8
7
|
INDEX_EXTENSIONS,
|
|
9
8
|
toDisplayName,
|
|
@@ -11,6 +10,7 @@ import {
|
|
|
11
10
|
getMenuSortAsFromFile,
|
|
12
11
|
getFolderLabel,
|
|
13
12
|
getFolderSortKey,
|
|
13
|
+
readFrontmatterInfo,
|
|
14
14
|
} from "./menuLabels.js";
|
|
15
15
|
|
|
16
16
|
// Icon extensions to check for custom icons
|
|
@@ -201,20 +201,15 @@ function buildMenuData(tree, source, validPaths, parentPath = '', includeDebug =
|
|
|
201
201
|
const relativePath = item.path.replace(source, '');
|
|
202
202
|
const folderPath = parentPath ? `${parentPath}/${baseName}` : baseName;
|
|
203
203
|
|
|
204
|
-
// Skip hidden files (config.json, style.css, etc.)
|
|
205
|
-
if (!hasChildren && hiddenFiles.includes(fileName)) {
|
|
204
|
+
// Skip hidden files (config.json, style.css, etc.) and menu files
|
|
205
|
+
if (!hasChildren && (hiddenFiles.includes(fileName) || isMenuFile(fileName))) {
|
|
206
206
|
continue;
|
|
207
207
|
}
|
|
208
208
|
|
|
209
209
|
// Skip metadata-only index files (they only provide folder metadata, not actual pages)
|
|
210
210
|
if (!hasChildren && isIndexFile(baseName)) {
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
if (isMetadataOnly(content)) {
|
|
214
|
-
continue; // Skip - this file doesn't produce a page
|
|
215
|
-
}
|
|
216
|
-
} catch (e) {
|
|
217
|
-
// If we can't read it, include it in the menu
|
|
211
|
+
if (readFrontmatterInfo(item.path)?.isMetadataOnly) {
|
|
212
|
+
continue; // Skip - this file doesn't produce a page
|
|
218
213
|
}
|
|
219
214
|
}
|
|
220
215
|
|
|
@@ -390,6 +385,37 @@ export function pruneHiddenNodes(node, source) {
|
|
|
390
385
|
};
|
|
391
386
|
}
|
|
392
387
|
|
|
388
|
+
/**
|
|
389
|
+
* Build a directory tree in the shape `directory-tree` produced
|
|
390
|
+
* ({name, path, children?}), reading through the traced filesystem so the
|
|
391
|
+
* build graph records every listing the menu depends on. Entries are sorted
|
|
392
|
+
* by name; readdir order never reaches the menu.
|
|
393
|
+
* @param {string} dir - Absolute directory path
|
|
394
|
+
* @returns {object|null} Tree node, or null if `dir` cannot be listed
|
|
395
|
+
*/
|
|
396
|
+
function walkTree(dir) {
|
|
397
|
+
let entries;
|
|
398
|
+
try {
|
|
399
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
400
|
+
} catch {
|
|
401
|
+
return null;
|
|
402
|
+
}
|
|
403
|
+
const children = [];
|
|
404
|
+
for (const entry of [...entries].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))) {
|
|
405
|
+
// Never inputs (see tracedFs.isIgnoredDirEntry); skipping them here also
|
|
406
|
+
// keeps the walk out of a .git or node_modules sitting inside the docroot.
|
|
407
|
+
if (isIgnoredDirEntry(entry.name)) continue;
|
|
408
|
+
const path = join(dir, entry.name);
|
|
409
|
+
if (entry.isDirectory()) {
|
|
410
|
+
const child = walkTree(path);
|
|
411
|
+
if (child) children.push(child);
|
|
412
|
+
} else if (entry.isFile()) {
|
|
413
|
+
children.push({ name: entry.name, path });
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
return { name: basename(dir), path: dir, children };
|
|
417
|
+
}
|
|
418
|
+
|
|
393
419
|
export async function getAutomenu(source, validPaths) {
|
|
394
420
|
/*
|
|
395
421
|
* Walk first, prune second.
|
|
@@ -406,7 +432,7 @@ export async function getAutomenu(source, validPaths) {
|
|
|
406
432
|
* discarded; docroots do not normally contain one, and correctness on every
|
|
407
433
|
* ordinary path is worth more than speed on a pathological one.
|
|
408
434
|
*/
|
|
409
|
-
const fullTree =
|
|
435
|
+
const fullTree = walkTree(source.replace(/\/$/, ''));
|
|
410
436
|
if (!fullTree) {
|
|
411
437
|
throw new Error(
|
|
412
438
|
`Cannot read docroot for menu generation: ${source} (does it exist and is it a directory?)`
|
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
import { join } from "path";
|
|
2
|
-
import { mkdtemp, mkdir, writeFile, rm
|
|
3
|
-
import { existsSync } from "fs";
|
|
2
|
+
import { mkdtemp, mkdir, writeFile, rm } from "fs/promises";
|
|
4
3
|
import { tmpdir } from "os";
|
|
5
|
-
import {
|
|
6
|
-
import { clearConfigCache } from "../../folderConfig.js";
|
|
4
|
+
import { generateAutoIndexHtmlFromSource } from "../autoIndex.js";
|
|
7
5
|
|
|
8
6
|
let tempDir;
|
|
9
7
|
let source;
|
|
@@ -19,82 +17,6 @@ afterEach(async () => {
|
|
|
19
17
|
await rm(tempDir, { recursive: true, force: true });
|
|
20
18
|
});
|
|
21
19
|
|
|
22
|
-
const TEMPLATE =
|
|
23
|
-
"<html><head>${styleLink}</head><body>${menu}${body}${footer}${customScript}</body></html>";
|
|
24
|
-
|
|
25
|
-
function makeProgress() {
|
|
26
|
-
const logs = [];
|
|
27
|
-
return {
|
|
28
|
-
logs,
|
|
29
|
-
log: (msg) => logs.push(msg),
|
|
30
|
-
status: () => {},
|
|
31
|
-
done: () => {},
|
|
32
|
-
};
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function runAutoIndices(directories, generatedArticles, progress) {
|
|
36
|
-
return generateAutoIndices(
|
|
37
|
-
output,
|
|
38
|
-
directories,
|
|
39
|
-
source,
|
|
40
|
-
{ "default-template": TEMPLATE },
|
|
41
|
-
"",
|
|
42
|
-
"",
|
|
43
|
-
generatedArticles,
|
|
44
|
-
new Set(),
|
|
45
|
-
new Set(),
|
|
46
|
-
"20260101000000",
|
|
47
|
-
progress,
|
|
48
|
-
null
|
|
49
|
-
);
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
describe("generateAutoIndices with empty source folders", () => {
|
|
53
|
-
it("skips output directories that were never created instead of logging an error", async () => {
|
|
54
|
-
// Source has an empty folder (guides) and a folder with a document (docs).
|
|
55
|
-
// Only docs produced output files, so output/guides does not exist.
|
|
56
|
-
await mkdir(join(source, "guides"));
|
|
57
|
-
await mkdir(join(source, "docs"));
|
|
58
|
-
await writeFile(join(source, "docs", "hello.md"), "# Hello\n\nWorld\n");
|
|
59
|
-
await mkdir(join(output, "docs"));
|
|
60
|
-
await writeFile(join(output, "docs", "hello.html"), "<html><body>Hello</body></html>");
|
|
61
|
-
|
|
62
|
-
const progress = makeProgress();
|
|
63
|
-
await runAutoIndices(
|
|
64
|
-
[join(source, "guides"), join(source, "docs")],
|
|
65
|
-
[join(source, "docs", "hello.md")],
|
|
66
|
-
progress
|
|
67
|
-
);
|
|
68
|
-
|
|
69
|
-
const errors = progress.logs.filter((m) => /Error generating auto-index/i.test(m));
|
|
70
|
-
expect(errors).toEqual([]);
|
|
71
|
-
// The missing output directory is skipped, not created
|
|
72
|
-
expect(existsSync(join(output, "guides"))).toBe(false);
|
|
73
|
-
expect(existsSync(join(output, "guides", "index.html"))).toBe(false);
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
it("still generates auto-indices for folders that produced output", async () => {
|
|
77
|
-
await mkdir(join(source, "guides"));
|
|
78
|
-
await mkdir(join(source, "docs"));
|
|
79
|
-
await writeFile(join(source, "docs", "hello.md"), "# Hello\n\nWorld\n");
|
|
80
|
-
await mkdir(join(output, "docs"));
|
|
81
|
-
await writeFile(join(output, "docs", "hello.html"), "<html><body>Hello</body></html>");
|
|
82
|
-
|
|
83
|
-
const progress = makeProgress();
|
|
84
|
-
await runAutoIndices(
|
|
85
|
-
[join(source, "guides"), join(source, "docs")],
|
|
86
|
-
[join(source, "docs", "hello.md")],
|
|
87
|
-
progress
|
|
88
|
-
);
|
|
89
|
-
|
|
90
|
-
// Root and docs both exist in output, so both get an index.html
|
|
91
|
-
const docsIndex = await readFile(join(output, "docs", "index.html"), "utf8");
|
|
92
|
-
expect(docsIndex).toContain('<a href="hello.html">');
|
|
93
|
-
const rootIndex = await readFile(join(output, "index.html"), "utf8");
|
|
94
|
-
expect(rootIndex).toContain('<a href="docs/index.html">');
|
|
95
|
-
});
|
|
96
|
-
});
|
|
97
|
-
|
|
98
20
|
describe("auto-index naming matches the automenu", () => {
|
|
99
21
|
it("uses menu-label from a folder's index frontmatter, and config.json when there is no index", async () => {
|
|
100
22
|
// bnw has a real index.md carrying the label; hfr has no index at all, so
|
|
@@ -140,35 +62,9 @@ describe("auto-index naming matches the automenu", () => {
|
|
|
140
62
|
|
|
141
63
|
expect(html).toContain('<a href="SoL/index.html">SoL</a>');
|
|
142
64
|
});
|
|
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
65
|
});
|
|
164
66
|
|
|
165
67
|
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
68
|
it("omits a hidden folder from an auto-index built from source", async () => {
|
|
173
69
|
await mkdir(join(source, "_art"), { recursive: true });
|
|
174
70
|
await writeFile(join(source, "_art", "config.json"), JSON.stringify({ hidden: true }));
|
|
@@ -183,32 +79,6 @@ describe("folders ignored via config.json { hidden: true }", () => {
|
|
|
183
79
|
expect(html).not.toContain("prompts");
|
|
184
80
|
});
|
|
185
81
|
|
|
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
82
|
it("does not count documents inside a hidden subfolder when deciding a folder has content", async () => {
|
|
213
83
|
// `notes` holds nothing but a hidden subfolder, so it produces no pages
|
|
214
84
|
// and must not be linked as though it did.
|