@kenjura/ursa 0.95.0 → 0.97.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 +48 -0
- package/README.md +114 -16
- package/bin/ursa.js +14 -1
- package/meta/templates/default-template/content-hooks.js +45 -0
- package/meta/templates/default-template/index.html +1 -0
- package/meta/templates/default-template/menu.js +18 -1
- package/meta/templates/default-template/search.js +11 -0
- package/meta/templates/default-template/sticky.js +7 -1
- package/meta/templates/default-template/toc-generator.js +58 -38
- 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__/mdxRenderer.test.js +159 -0
- package/src/helper/__test__/sourceTimestamps.test.js +0 -0
- package/src/helper/assetBundler.js +93 -19
- package/src/helper/automenu.js +36 -11
- 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 +553 -0
- package/src/helper/build/autoIndex.js +2 -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 +1270 -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 +1 -1
- 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/linkValidator.js +118 -127
- package/src/helper/mdxRenderer.js +225 -26
- package/src/helper/menuLabels.js +30 -5
- package/src/helper/sourceTimestamps.js +139 -0
- package/src/helper/ursaConfig.js +3 -49
- package/src/helper/whitelistFilter.js +1 -2
- package/src/jobs/generate.js +67 -1859
- 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
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { join } from 'path';
|
|
2
|
+
import { mkdtemp, writeFile, mkdir, rm } from 'fs/promises';
|
|
3
|
+
import { tmpdir } from 'os';
|
|
4
|
+
import { renderMDX, generateHydrationScript } from '../mdxRenderer.js';
|
|
5
|
+
|
|
6
|
+
// Helper: a temp source tree with a _components/ directory
|
|
7
|
+
let tempDir;
|
|
8
|
+
beforeEach(async () => {
|
|
9
|
+
tempDir = await mkdtemp(join(tmpdir(), 'ursa-mdx-'));
|
|
10
|
+
await mkdir(join(tempDir, '_components'), { recursive: true });
|
|
11
|
+
});
|
|
12
|
+
afterEach(async () => {
|
|
13
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
async function writeComponent(name, body) {
|
|
17
|
+
const path = join(tempDir, '_components', `${name}.jsx`);
|
|
18
|
+
await writeFile(path, body);
|
|
19
|
+
return path;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function render(mdx, { hydrate = true } = {}) {
|
|
23
|
+
const filePath = join(tempDir, 'page.mdx');
|
|
24
|
+
await writeFile(filePath, mdx);
|
|
25
|
+
return renderMDX({ source: mdx, filePath, sourceRoot: tempDir, hydrate });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const COUNTER = `
|
|
29
|
+
import React from 'react';
|
|
30
|
+
export default function Counter({ label }) {
|
|
31
|
+
const [n, setN] = React.useState(0);
|
|
32
|
+
return <button onClick={() => setN(n + 1)}>{label}: {n}</button>;
|
|
33
|
+
}
|
|
34
|
+
`;
|
|
35
|
+
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// Island wrapping
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
describe('renderMDX islands', () => {
|
|
40
|
+
test('wraps a component imported from the MDX in <ursa-island>', async () => {
|
|
41
|
+
await writeComponent('Counter', COUNTER);
|
|
42
|
+
const { html } = await render(`
|
|
43
|
+
import Counter from '_components/Counter.jsx';
|
|
44
|
+
|
|
45
|
+
# Page
|
|
46
|
+
|
|
47
|
+
<Counter label="Clicks" />
|
|
48
|
+
`);
|
|
49
|
+
expect(html).toMatch(/<ursa-island data-island="0" data-component="Counter"[^>]*>/);
|
|
50
|
+
expect(html).toContain('<button>Clicks<!-- -->: <!-- -->0</button>');
|
|
51
|
+
expect(html).toContain('</ursa-island>');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('numbers islands in document order', async () => {
|
|
55
|
+
await writeComponent('Counter', COUNTER);
|
|
56
|
+
const { html } = await render(`
|
|
57
|
+
import Counter from '_components/Counter.jsx';
|
|
58
|
+
|
|
59
|
+
<Counter label="A" />
|
|
60
|
+
|
|
61
|
+
Some prose.
|
|
62
|
+
|
|
63
|
+
<Counter label="B" />
|
|
64
|
+
`);
|
|
65
|
+
const ids = [...html.matchAll(/<ursa-island data-island="(\d+)"/g)].map((m) => m[1]);
|
|
66
|
+
expect(ids).toEqual(['0', '1']);
|
|
67
|
+
expect(html.indexOf('>A<')).toBeLessThan(html.indexOf('>B<'));
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('does not nest islands: a component imported by a component is rendered plainly', async () => {
|
|
71
|
+
await writeComponent('Inner', `
|
|
72
|
+
import React from 'react';
|
|
73
|
+
export default function Inner() { return <em>inner</em>; }
|
|
74
|
+
`);
|
|
75
|
+
await writeComponent('Outer', `
|
|
76
|
+
import React from 'react';
|
|
77
|
+
import Inner from './Inner.jsx';
|
|
78
|
+
export default function Outer() { return <div>outer <Inner /></div>; }
|
|
79
|
+
`);
|
|
80
|
+
const { html } = await render(`
|
|
81
|
+
import Outer from '_components/Outer.jsx';
|
|
82
|
+
|
|
83
|
+
<Outer />
|
|
84
|
+
`);
|
|
85
|
+
expect(html.match(/<ursa-island/g)).toHaveLength(1);
|
|
86
|
+
expect(html).toContain('data-component="Outer"');
|
|
87
|
+
expect(html).toContain('<em>inner</em>');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('leaves the surrounding markdown outside any island', async () => {
|
|
91
|
+
await writeComponent('Counter', COUNTER);
|
|
92
|
+
const { html } = await render(`
|
|
93
|
+
import Counter from '_components/Counter.jsx';
|
|
94
|
+
|
|
95
|
+
# Heading
|
|
96
|
+
|
|
97
|
+
<Counter label="x" />
|
|
98
|
+
|
|
99
|
+
## Sub
|
|
100
|
+
`);
|
|
101
|
+
// Headings are siblings of the island, not children of it
|
|
102
|
+
expect(html).toMatch(/<h1>Heading<\/h1>\s*<ursa-island/);
|
|
103
|
+
expect(html).toMatch(/<\/ursa-island>\s*<h2>Sub<\/h2>/);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('client bundle contains the island runtime', async () => {
|
|
107
|
+
await writeComponent('Counter', COUNTER);
|
|
108
|
+
const { clientCode } = await render(`
|
|
109
|
+
import Counter from '_components/Counter.jsx';
|
|
110
|
+
|
|
111
|
+
<Counter label="x" />
|
|
112
|
+
`);
|
|
113
|
+
expect(clientCode).toContain('hydrateRoot');
|
|
114
|
+
expect(clientCode).toContain('data-island');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('hydrate: false still renders islands but emits no client code', async () => {
|
|
118
|
+
await writeComponent('Counter', COUNTER);
|
|
119
|
+
const result = await render(`
|
|
120
|
+
import Counter from '_components/Counter.jsx';
|
|
121
|
+
|
|
122
|
+
<Counter label="x" />
|
|
123
|
+
`, { hydrate: false });
|
|
124
|
+
expect(result.html).toContain('<ursa-island');
|
|
125
|
+
expect(result.clientCode).toBeUndefined();
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
// Preload stripping
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
describe('renderMDX preload stripping', () => {
|
|
133
|
+
test('removes React 19 hoisted <link rel="preload"> so the body starts with its heading', async () => {
|
|
134
|
+
const { html } = await render(`
|
|
135
|
+
# Title
|
|
136
|
+

|
|
137
|
+
`);
|
|
138
|
+
expect(html).not.toContain('rel="preload"');
|
|
139
|
+
expect(html.trimStart().startsWith('<h1>')).toBe(true);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
// Hydration script
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
describe('generateHydrationScript', () => {
|
|
147
|
+
test('renders into a detached root and looks up islands rather than a page container', () => {
|
|
148
|
+
const script = generateHydrationScript('return { default: function () { return null; } };');
|
|
149
|
+
expect(script).toContain('ursa-island[data-island]');
|
|
150
|
+
expect(script).toContain('createRoot(detached)');
|
|
151
|
+
expect(script).not.toContain("getElementById('main-content')");
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('escapes the bundle for embedding in a template literal', () => {
|
|
155
|
+
const script = generateHydrationScript('var s = `a${b}`; // </script>');
|
|
156
|
+
expect(script).toContain('\\`a\\${b}\\`');
|
|
157
|
+
expect(script).toContain('<\\/script>');
|
|
158
|
+
});
|
|
159
|
+
});
|
|
Binary file
|
|
@@ -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,7 @@
|
|
|
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 { isMetadataOnly } from "./metadataExtractor.js";
|
|
7
5
|
import {
|
|
8
6
|
INDEX_EXTENSIONS,
|
|
9
7
|
toDisplayName,
|
|
@@ -11,6 +9,7 @@ import {
|
|
|
11
9
|
getMenuSortAsFromFile,
|
|
12
10
|
getFolderLabel,
|
|
13
11
|
getFolderSortKey,
|
|
12
|
+
readFrontmatterInfo,
|
|
14
13
|
} from "./menuLabels.js";
|
|
15
14
|
|
|
16
15
|
// Icon extensions to check for custom icons
|
|
@@ -208,13 +207,8 @@ function buildMenuData(tree, source, validPaths, parentPath = '', includeDebug =
|
|
|
208
207
|
|
|
209
208
|
// Skip metadata-only index files (they only provide folder metadata, not actual pages)
|
|
210
209
|
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
|
|
210
|
+
if (readFrontmatterInfo(item.path)?.isMetadataOnly) {
|
|
211
|
+
continue; // Skip - this file doesn't produce a page
|
|
218
212
|
}
|
|
219
213
|
}
|
|
220
214
|
|
|
@@ -390,6 +384,37 @@ export function pruneHiddenNodes(node, source) {
|
|
|
390
384
|
};
|
|
391
385
|
}
|
|
392
386
|
|
|
387
|
+
/**
|
|
388
|
+
* Build a directory tree in the shape `directory-tree` produced
|
|
389
|
+
* ({name, path, children?}), reading through the traced filesystem so the
|
|
390
|
+
* build graph records every listing the menu depends on. Entries are sorted
|
|
391
|
+
* by name; readdir order never reaches the menu.
|
|
392
|
+
* @param {string} dir - Absolute directory path
|
|
393
|
+
* @returns {object|null} Tree node, or null if `dir` cannot be listed
|
|
394
|
+
*/
|
|
395
|
+
function walkTree(dir) {
|
|
396
|
+
let entries;
|
|
397
|
+
try {
|
|
398
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
399
|
+
} catch {
|
|
400
|
+
return null;
|
|
401
|
+
}
|
|
402
|
+
const children = [];
|
|
403
|
+
for (const entry of [...entries].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))) {
|
|
404
|
+
// Never inputs (see tracedFs.isIgnoredDirEntry); skipping them here also
|
|
405
|
+
// keeps the walk out of a .git or node_modules sitting inside the docroot.
|
|
406
|
+
if (isIgnoredDirEntry(entry.name)) continue;
|
|
407
|
+
const path = join(dir, entry.name);
|
|
408
|
+
if (entry.isDirectory()) {
|
|
409
|
+
const child = walkTree(path);
|
|
410
|
+
if (child) children.push(child);
|
|
411
|
+
} else if (entry.isFile()) {
|
|
412
|
+
children.push({ name: entry.name, path });
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
return { name: basename(dir), path: dir, children };
|
|
416
|
+
}
|
|
417
|
+
|
|
393
418
|
export async function getAutomenu(source, validPaths) {
|
|
394
419
|
/*
|
|
395
420
|
* Walk first, prune second.
|
|
@@ -406,7 +431,7 @@ export async function getAutomenu(source, validPaths) {
|
|
|
406
431
|
* discarded; docroots do not normally contain one, and correctness on every
|
|
407
432
|
* ordinary path is worth more than speed on a pathological one.
|
|
408
433
|
*/
|
|
409
|
-
const fullTree =
|
|
434
|
+
const fullTree = walkTree(source.replace(/\/$/, ''));
|
|
410
435
|
if (!fullTree) {
|
|
411
436
|
throw new Error(
|
|
412
437
|
`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.
|