@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
|
@@ -5,10 +5,18 @@ import { basename, dirname, extname, join } from "path";
|
|
|
5
5
|
import { outputFile } from "fs-extra";
|
|
6
6
|
import { findStyleCss, findAllStyleCss } from "../findStyleCss.js";
|
|
7
7
|
import { findAllScriptJs } from "../findScriptJs.js";
|
|
8
|
-
import { toTitleCase } from "./titleCase.js";
|
|
9
8
|
import { addTimestampToHtmlStaticRefs } from "./cacheBust.js";
|
|
10
9
|
import { isMetadataOnly, extractMetadata, getAutoIndexConfig } from "../metadataExtractor.js";
|
|
11
10
|
import { getCustomMenuForFile } from "./menu.js";
|
|
11
|
+
import { getFolderConfig } from "../folderConfig.js";
|
|
12
|
+
import {
|
|
13
|
+
toDisplayName,
|
|
14
|
+
getFolderLabel,
|
|
15
|
+
getFolderSortKey,
|
|
16
|
+
getMenuSortAsFromFile,
|
|
17
|
+
getFileLabel,
|
|
18
|
+
findSourceDocument,
|
|
19
|
+
} from "../menuLabels.js";
|
|
12
20
|
import { generateBreadcrumbs } from "../breadcrumbs.js";
|
|
13
21
|
import { bundleDocumentCss, bundleDocumentJs } from "../assetBundler.js";
|
|
14
22
|
|
|
@@ -40,6 +48,44 @@ async function directoryHasDocuments(dir, extensions) {
|
|
|
40
48
|
return false;
|
|
41
49
|
}
|
|
42
50
|
|
|
51
|
+
/**
|
|
52
|
+
* Resolve the label and sort key for one auto-index entry, using the same
|
|
53
|
+
* rules as the site-wide automenu: `menu-label` frontmatter wins, then
|
|
54
|
+
* config.json `label` for folders, then the prettified file/folder name.
|
|
55
|
+
*
|
|
56
|
+
* @param {boolean} isDir - Whether the entry is a directory
|
|
57
|
+
* @param {string} baseName - Name without extension
|
|
58
|
+
* @param {string|null} sourceDir - Source directory holding this entry, if known
|
|
59
|
+
* @returns {{label: string, sortKey: string}}
|
|
60
|
+
*/
|
|
61
|
+
function resolveEntryNaming(isDir, baseName, sourceDir) {
|
|
62
|
+
if (!sourceDir) {
|
|
63
|
+
return { label: toDisplayName(baseName), sortKey: baseName };
|
|
64
|
+
}
|
|
65
|
+
if (isDir) {
|
|
66
|
+
const childDir = join(sourceDir, baseName);
|
|
67
|
+
return {
|
|
68
|
+
label: getFolderLabel(childDir, getFolderConfig(childDir), baseName),
|
|
69
|
+
sortKey: getFolderSortKey(childDir) || baseName,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
const sourceFile = findSourceDocument(sourceDir, baseName);
|
|
73
|
+
return {
|
|
74
|
+
label: getFileLabel(sourceFile, baseName),
|
|
75
|
+
sortKey: (sourceFile && getMenuSortAsFromFile(sourceFile)) || baseName,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Sort auto-index entries the way the automenu does: folders first, then by
|
|
81
|
+
* sort key (case-insensitive).
|
|
82
|
+
*/
|
|
83
|
+
function compareEntries(a, b) {
|
|
84
|
+
if (a.isDir && !b.isDir) return -1;
|
|
85
|
+
if (!a.isDir && b.isDir) return 1;
|
|
86
|
+
return a.sortKey.toLowerCase().localeCompare(b.sortKey.toLowerCase());
|
|
87
|
+
}
|
|
88
|
+
|
|
43
89
|
/**
|
|
44
90
|
* Generate auto-index HTML content for a directory from the OUTPUT folder
|
|
45
91
|
* (used by fallback auto-index generation after all files are generated)
|
|
@@ -47,9 +93,12 @@ async function directoryHasDocuments(dir, extensions) {
|
|
|
47
93
|
* @param {number} depth - How deep to recurse (1 = current level only, 2 = current + children, etc.)
|
|
48
94
|
* @param {number} [currentDepth=0] - Current recursion depth (internal use)
|
|
49
95
|
* @param {string} [pathPrefix=''] - Path prefix for generating correct hrefs (internal use)
|
|
96
|
+
* @param {string|null} [sourceDir=null] - Matching source directory, so `menu-label`
|
|
97
|
+
* frontmatter and config.json labels can be honored. Without it, entries fall
|
|
98
|
+
* back to their prettified file names.
|
|
50
99
|
* @returns {Promise<string>} HTML content for the auto-index
|
|
51
100
|
*/
|
|
52
|
-
export async function generateAutoIndexHtml(dir, depth = 1, currentDepth = 0, pathPrefix = '') {
|
|
101
|
+
export async function generateAutoIndexHtml(dir, depth = 1, currentDepth = 0, pathPrefix = '', sourceDir = null) {
|
|
53
102
|
try {
|
|
54
103
|
const children = await readdir(dir, { withFileTypes: true });
|
|
55
104
|
|
|
@@ -65,12 +114,12 @@ export async function generateAutoIndexHtml(dir, depth = 1, currentDepth = 0, pa
|
|
|
65
114
|
// Include directories and html files
|
|
66
115
|
return child.isDirectory() || child.name.endsWith('.html');
|
|
67
116
|
})
|
|
68
|
-
.
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
117
|
+
.map(child => {
|
|
118
|
+
const isDir = child.isDirectory();
|
|
119
|
+
const baseName = isDir ? child.name : child.name.replace(/\.html$/, '');
|
|
120
|
+
return { child, isDir, baseName, ...resolveEntryNaming(isDir, baseName, sourceDir) };
|
|
121
|
+
})
|
|
122
|
+
.sort(compareEntries);
|
|
74
123
|
|
|
75
124
|
if (filteredChildren.length === 0) {
|
|
76
125
|
return '';
|
|
@@ -78,26 +127,26 @@ export async function generateAutoIndexHtml(dir, depth = 1, currentDepth = 0, pa
|
|
|
78
127
|
|
|
79
128
|
const items = [];
|
|
80
129
|
|
|
81
|
-
for (const child of filteredChildren) {
|
|
82
|
-
const isDir = child.isDirectory();
|
|
130
|
+
for (const { child, isDir, label } of filteredChildren) {
|
|
83
131
|
// Skip directories that contain no documents
|
|
84
132
|
if (isDir) {
|
|
85
133
|
const childDir = join(dir, child.name);
|
|
86
134
|
if (!await directoryHasDocuments(childDir, OUTPUT_DOC_EXTENSIONS)) continue;
|
|
87
135
|
}
|
|
88
|
-
const name = isDir ? child.name : child.name.replace('.html', '');
|
|
89
136
|
// Use pathPrefix to ensure hrefs are correct relative to the document root
|
|
90
137
|
const childPath = pathPrefix ? `${pathPrefix}/${child.name}` : child.name;
|
|
91
138
|
const href = isDir ? `${childPath}/index.html` : (pathPrefix ? `${pathPrefix}/${child.name}` : child.name);
|
|
92
|
-
const displayName = toTitleCase(name);
|
|
93
139
|
const icon = isDir ? '📁' : '📄';
|
|
94
140
|
|
|
95
|
-
let itemHtml = `<li>${icon} <a href="${href}">${
|
|
141
|
+
let itemHtml = `<li>${icon} <a href="${href}">${label}</a>`;
|
|
96
142
|
|
|
97
143
|
// If this is a directory and we need to go deeper, recurse
|
|
98
144
|
if (isDir && currentDepth + 1 < depth) {
|
|
99
145
|
const childDir = join(dir, child.name);
|
|
100
|
-
const childHtml = await generateAutoIndexHtml(
|
|
146
|
+
const childHtml = await generateAutoIndexHtml(
|
|
147
|
+
childDir, depth, currentDepth + 1, childPath,
|
|
148
|
+
sourceDir ? join(sourceDir, child.name) : null
|
|
149
|
+
);
|
|
101
150
|
if (childHtml) {
|
|
102
151
|
itemHtml += `\n${childHtml}`;
|
|
103
152
|
}
|
|
@@ -140,12 +189,12 @@ export async function generateAutoIndexHtmlFromSource(sourceDir, depth = 1, curr
|
|
|
140
189
|
// Include directories and article files (md, mdx, txt, yml, html)
|
|
141
190
|
return child.isDirectory() || child.name.match(/\.(md|mdx|txt|yml|html)$/i);
|
|
142
191
|
})
|
|
143
|
-
.
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
192
|
+
.map(child => {
|
|
193
|
+
const isDir = child.isDirectory();
|
|
194
|
+
const baseName = isDir ? child.name : basename(child.name, extname(child.name));
|
|
195
|
+
return { child, isDir, baseName, ...resolveEntryNaming(isDir, baseName, sourceDir) };
|
|
196
|
+
})
|
|
197
|
+
.sort(compareEntries);
|
|
149
198
|
|
|
150
199
|
if (filteredChildren.length === 0) {
|
|
151
200
|
return '';
|
|
@@ -153,24 +202,19 @@ export async function generateAutoIndexHtmlFromSource(sourceDir, depth = 1, curr
|
|
|
153
202
|
|
|
154
203
|
const items = [];
|
|
155
204
|
|
|
156
|
-
for (const child of filteredChildren) {
|
|
157
|
-
const isDir = child.isDirectory();
|
|
205
|
+
for (const { child, isDir, baseName, label } of filteredChildren) {
|
|
158
206
|
// Skip directories that contain no documents
|
|
159
207
|
if (isDir) {
|
|
160
208
|
const childDir = join(sourceDir, child.name);
|
|
161
209
|
if (!await directoryHasDocuments(childDir, SOURCE_DOC_EXTENSIONS)) continue;
|
|
162
210
|
}
|
|
163
|
-
// Get name without extension for display
|
|
164
|
-
const ext = isDir ? '' : extname(child.name);
|
|
165
|
-
const nameWithoutExt = isDir ? child.name : basename(child.name, ext);
|
|
166
211
|
// Generate href - directories link to folder/index.html, files convert to .html
|
|
167
212
|
// Use pathPrefix to ensure hrefs are correct relative to the document root
|
|
168
213
|
const childPath = pathPrefix ? `${pathPrefix}/${child.name}` : child.name;
|
|
169
|
-
const href = isDir ? `${childPath}/index.html` : `${pathPrefix ? pathPrefix + '/' : ''}${
|
|
170
|
-
const displayName = toTitleCase(nameWithoutExt);
|
|
214
|
+
const href = isDir ? `${childPath}/index.html` : `${pathPrefix ? pathPrefix + '/' : ''}${baseName}.html`;
|
|
171
215
|
const icon = isDir ? '📁' : '📄';
|
|
172
216
|
|
|
173
|
-
let itemHtml = `<li>${icon} <a href="${href}">${
|
|
217
|
+
let itemHtml = `<li>${icon} <a href="${href}">${label}</a>`;
|
|
174
218
|
|
|
175
219
|
// If this is a directory and we need to go deeper, recurse
|
|
176
220
|
if (isDir && currentDepth + 1 < depth) {
|
|
@@ -313,28 +357,32 @@ export async function generateAutoIndices(output, directories, source, templates
|
|
|
313
357
|
const children = await readdir(dir, { withFileTypes: true });
|
|
314
358
|
|
|
315
359
|
// Filter to only include relevant files and folders
|
|
316
|
-
const filteredItems = children
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
360
|
+
const filteredItems = children
|
|
361
|
+
.filter(child => {
|
|
362
|
+
// Skip hidden files and index alternates we just checked
|
|
363
|
+
if (child.name.startsWith('.')) return false;
|
|
364
|
+
if (child.name === 'index.html') return false;
|
|
365
|
+
// Include directories and html files
|
|
366
|
+
return child.isDirectory() || child.name.endsWith('.html');
|
|
367
|
+
})
|
|
368
|
+
.map(child => {
|
|
369
|
+
const isDir = child.isDirectory();
|
|
370
|
+
const baseName = isDir ? child.name : child.name.replace(/\.html$/, '');
|
|
371
|
+
return { child, isDir, baseName, ...resolveEntryNaming(isDir, baseName, sourceDir) };
|
|
372
|
+
})
|
|
373
|
+
.sort(compareEntries);
|
|
323
374
|
|
|
324
375
|
// Build items, skipping directories with no documents
|
|
325
376
|
const items = [];
|
|
326
|
-
for (const child of filteredItems) {
|
|
327
|
-
const isDir = child.isDirectory();
|
|
377
|
+
for (const { child, isDir, label } of filteredItems) {
|
|
328
378
|
if (isDir) {
|
|
329
379
|
const childDir = join(dir, child.name);
|
|
330
380
|
if (!await directoryHasDocuments(childDir, OUTPUT_DOC_EXTENSIONS)) continue;
|
|
331
381
|
}
|
|
332
|
-
const name = isDir ? child.name : child.name.replace('.html', '');
|
|
333
382
|
// For directories, link to /folder/index.html; for files, use the filename directly
|
|
334
383
|
const href = isDir ? `${child.name}/index.html` : child.name;
|
|
335
|
-
const displayName = toTitleCase(name);
|
|
336
384
|
const icon = isDir ? '📁' : '📄';
|
|
337
|
-
items.push(`<li>${icon} <a href="${href}">${
|
|
385
|
+
items.push(`<li>${icon} <a href="${href}">${label}</a></li>`);
|
|
338
386
|
}
|
|
339
387
|
|
|
340
388
|
if (items.length === 0) {
|
|
@@ -342,12 +390,16 @@ export async function generateAutoIndices(output, directories, source, templates
|
|
|
342
390
|
continue;
|
|
343
391
|
}
|
|
344
392
|
|
|
345
|
-
|
|
393
|
+
// The page's own heading and <title> follow the same naming rules, so a
|
|
394
|
+
// folder labelled "BNW - Brave New World" in the menu is not "Bnw" here.
|
|
395
|
+
const folderDisplayName = dir === outputNorm
|
|
396
|
+
? 'Home'
|
|
397
|
+
: getFolderLabel(sourceDir, getFolderConfig(sourceDir), folderName);
|
|
346
398
|
|
|
347
399
|
// Generate breadcrumbs for auto-index pages
|
|
348
400
|
const relDir = dir.replace(outputNorm, '').replace(/^\//, '');
|
|
349
401
|
const breadcrumbDir = relDir ? relDir + '/' : '/';
|
|
350
|
-
const breadcrumbHtml = generateBreadcrumbs(breadcrumbDir, 'index', null);
|
|
402
|
+
const breadcrumbHtml = generateBreadcrumbs(breadcrumbDir, 'index', null, sourceNorm);
|
|
351
403
|
|
|
352
404
|
const indexHtml = `${breadcrumbHtml}<h1>${folderDisplayName}</h1>\n<ul class="auto-index">\n${items.join('\n')}\n</ul>`;
|
|
353
405
|
|
|
@@ -1,27 +1,8 @@
|
|
|
1
1
|
// Helper for building the _ursa_metadata field embedded in generated JSON files
|
|
2
2
|
import { existsSync } from "fs";
|
|
3
3
|
import { readFile } from "fs/promises";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* Read the ursa version from ursa's own package.json
|
|
9
|
-
* @returns {Promise<string>} The ursa version, or 'unknown' if it can't be read
|
|
10
|
-
*/
|
|
11
|
-
async function getUrsaVersion() {
|
|
12
|
-
try {
|
|
13
|
-
// From src/helper/build/ursaMetadata.js, go up to the package root
|
|
14
|
-
const currentDir = dirname(new URL(import.meta.url).pathname);
|
|
15
|
-
const ursaPackagePath = resolve(currentDir, "..", "..", "..", "package.json");
|
|
16
|
-
if (existsSync(ursaPackagePath)) {
|
|
17
|
-
const ursaPackage = JSON.parse(await readFile(ursaPackagePath, "utf8"));
|
|
18
|
-
if (ursaPackage.version) return ursaPackage.version;
|
|
19
|
-
}
|
|
20
|
-
} catch (e) {
|
|
21
|
-
console.error(`Error reading ursa package.json: ${e.message}`);
|
|
22
|
-
}
|
|
23
|
-
return "unknown";
|
|
24
|
-
}
|
|
4
|
+
import { join, resolve } from "path";
|
|
5
|
+
import { getUrsaVersion } from "../ursaVersion.js";
|
|
25
6
|
|
|
26
7
|
/**
|
|
27
8
|
* Read the documentation repo version from its package.json.
|
|
@@ -54,9 +35,5 @@ async function getDocVersion(_source) {
|
|
|
54
35
|
* @returns {Promise<{ursaVersion: string, docVersion: string}>}
|
|
55
36
|
*/
|
|
56
37
|
export async function getUrsaMetadata(_source) {
|
|
57
|
-
|
|
58
|
-
getUrsaVersion(),
|
|
59
|
-
getDocVersion(_source),
|
|
60
|
-
]);
|
|
61
|
-
return { ursaVersion, docVersion };
|
|
38
|
+
return { ursaVersion: getUrsaVersion(), docVersion: await getDocVersion(_source) };
|
|
62
39
|
}
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { createHash } from 'crypto';
|
|
2
|
-
import { readFile, writeFile, mkdir } from 'fs/promises';
|
|
2
|
+
import { readFile, writeFile, mkdir, rm } from 'fs/promises';
|
|
3
3
|
import { existsSync } from 'fs';
|
|
4
4
|
import { dirname, join } from 'path';
|
|
5
|
+
import { getUrsaVersion } from './ursaVersion.js';
|
|
5
6
|
|
|
6
7
|
const URSA_DIR = '.ursa';
|
|
7
8
|
const HASH_CACHE_FILE = 'content-hashes.json';
|
|
9
|
+
const CACHE_STAMP_FILE = 'cache-stamp.json';
|
|
8
10
|
|
|
9
11
|
/**
|
|
10
12
|
* Get the path to the .ursa directory for a given source directory
|
|
@@ -13,6 +15,57 @@ export function getUrsaDir(sourceDir) {
|
|
|
13
15
|
return join(sourceDir, URSA_DIR);
|
|
14
16
|
}
|
|
15
17
|
|
|
18
|
+
/**
|
|
19
|
+
* Stamp `.ursa/` with the running ursa version, discarding the whole directory
|
|
20
|
+
* first if it was written by a different one.
|
|
21
|
+
*
|
|
22
|
+
* Hash-skipping compares *source* content only. It says nothing about the code
|
|
23
|
+
* that turned that source into output, so a cache warmed by an older ursa keeps
|
|
24
|
+
* whole documents from being re-rendered even after the templates, renderers and
|
|
25
|
+
* asset bundles that produced them have changed — the symptom users hit as
|
|
26
|
+
* "I had to run --clean again". Run this before loading any cache.
|
|
27
|
+
*
|
|
28
|
+
* On a first build there is nothing to discard, and `reset` is false.
|
|
29
|
+
*
|
|
30
|
+
* @param {string} sourceDir - Source directory root
|
|
31
|
+
* @param {string} [version] - Version to stamp with; defaults to ursa's own
|
|
32
|
+
* @returns {Promise<{reset: boolean, previous: string|null, version: string}>}
|
|
33
|
+
*/
|
|
34
|
+
export async function enforceCacheVersion(sourceDir, version = getUrsaVersion()) {
|
|
35
|
+
const ursaDir = getUrsaDir(sourceDir);
|
|
36
|
+
const stampPath = join(ursaDir, CACHE_STAMP_FILE);
|
|
37
|
+
|
|
38
|
+
let previous = null;
|
|
39
|
+
try {
|
|
40
|
+
if (existsSync(stampPath)) {
|
|
41
|
+
const stamp = JSON.parse(await readFile(stampPath, 'utf8'));
|
|
42
|
+
previous = typeof stamp?.ursaVersion === 'string' ? stamp.ursaVersion : null;
|
|
43
|
+
}
|
|
44
|
+
} catch (e) {
|
|
45
|
+
// An unreadable stamp tells us nothing about what wrote the cache, so treat
|
|
46
|
+
// it the same as a mismatch rather than trusting the caches beside it.
|
|
47
|
+
previous = null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (previous === version) return { reset: false, previous, version };
|
|
51
|
+
|
|
52
|
+
// A cache directory with no stamp predates stamping (or was hand-edited);
|
|
53
|
+
// either way its contents were not written by this ursa.
|
|
54
|
+
const hadCache = existsSync(ursaDir);
|
|
55
|
+
if (hadCache) await rm(ursaDir, { recursive: true, force: true });
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
await mkdir(ursaDir, { recursive: true });
|
|
59
|
+
await writeFile(stampPath, JSON.stringify({ ursaVersion: version }, null, 2));
|
|
60
|
+
} catch (e) {
|
|
61
|
+
// A cache we cannot stamp is a cache we will discard again next run:
|
|
62
|
+
// correct, just slower. Not worth failing the build over.
|
|
63
|
+
console.warn('Could not write cache stamp:', e.message);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return { reset: hadCache, previous, version };
|
|
67
|
+
}
|
|
68
|
+
|
|
16
69
|
/**
|
|
17
70
|
* Generate a short hash of content
|
|
18
71
|
*/
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Shared menu label/sort-key resolution.
|
|
3
|
+
*
|
|
4
|
+
* Both the site-wide automenu (helper/automenu.js) and the per-folder
|
|
5
|
+
* auto-index listings (helper/build/autoIndex.js) name the same folders and
|
|
6
|
+
* documents, so they must agree on what those things are called. Keeping the
|
|
7
|
+
* resolution rules here is what makes `menu-label: 'BNW - Brave New World'`
|
|
8
|
+
* show up in both places instead of only in the sidebar.
|
|
9
|
+
*/
|
|
10
|
+
import { existsSync, readFileSync } from "fs";
|
|
11
|
+
import { basename, extname, join } from "path";
|
|
12
|
+
import { extractMetadata } from "./metadataExtractor.js";
|
|
13
|
+
import { stripHtml } from "./stripHtml.js";
|
|
14
|
+
|
|
15
|
+
// Index file extensions to check for folder metadata
|
|
16
|
+
export const INDEX_EXTENSIONS = ['.md', '.mdx', '.txt', '.yml', '.yaml'];
|
|
17
|
+
|
|
18
|
+
// Source extensions a rendered .html page can have come from
|
|
19
|
+
export const SOURCE_DOC_EXTENSIONS = ['.md', '.mdx', '.txt', '.yml', '.yaml', '.html'];
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Convert filename to display name (e.g., "foo-bar" -> "Foo Bar").
|
|
23
|
+
* Unlike toTitleCase, this preserves interior capitalization, so a folder
|
|
24
|
+
* named "SoL" stays "SoL" rather than becoming "Sol".
|
|
25
|
+
*/
|
|
26
|
+
export function toDisplayName(filename) {
|
|
27
|
+
return filename
|
|
28
|
+
.replace(/[-_]/g, ' ') // Replace dashes and underscores with spaces
|
|
29
|
+
.replace(/\b\w/g, c => c.toUpperCase()); // Capitalize first letter of each word
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Read a single frontmatter key from a file, with HTML stripped.
|
|
34
|
+
* @param {string} filePath - Path to the source file
|
|
35
|
+
* @param {string} key - Frontmatter key to read
|
|
36
|
+
* @returns {string|null} The value, or null if absent/unreadable
|
|
37
|
+
*/
|
|
38
|
+
function getFrontmatterString(filePath, key) {
|
|
39
|
+
try {
|
|
40
|
+
if (!existsSync(filePath)) return null;
|
|
41
|
+
const content = readFileSync(filePath, 'utf8');
|
|
42
|
+
const metadata = extractMetadata(content);
|
|
43
|
+
if (metadata && metadata[key]) {
|
|
44
|
+
return stripHtml(String(metadata[key]));
|
|
45
|
+
}
|
|
46
|
+
} catch (e) {
|
|
47
|
+
// Ignore read errors
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Get the menu label from a file's frontmatter
|
|
54
|
+
* @param {string} filePath - Path to the markdown file
|
|
55
|
+
* @returns {string|null} The menu-label value (with HTML stripped), or null if not found
|
|
56
|
+
*/
|
|
57
|
+
export function getMenuLabelFromFile(filePath) {
|
|
58
|
+
return getFrontmatterString(filePath, 'menu-label');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Get the menu-sort-as value from a file's frontmatter
|
|
63
|
+
* @param {string} filePath - Path to the markdown file
|
|
64
|
+
* @returns {string|null} The menu-sort-as value (with HTML stripped), or null if not found
|
|
65
|
+
*/
|
|
66
|
+
export function getMenuSortAsFromFile(filePath) {
|
|
67
|
+
return getFrontmatterString(filePath, 'menu-sort-as');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Get the menu label for a folder from its index.md frontmatter
|
|
72
|
+
* Falls back to config.json label (deprecated), then display name
|
|
73
|
+
* @param {string} dirPath - Path to the folder
|
|
74
|
+
* @param {object|null} folderConfig - The folder's config.json if any
|
|
75
|
+
* @param {string} baseName - The folder's base name
|
|
76
|
+
* @returns {string} The label to display
|
|
77
|
+
*/
|
|
78
|
+
export function getFolderLabel(dirPath, folderConfig, baseName) {
|
|
79
|
+
// First, check index.md for menu-label (preferred method)
|
|
80
|
+
for (const ext of INDEX_EXTENSIONS) {
|
|
81
|
+
const indexPath = join(dirPath, `index${ext}`);
|
|
82
|
+
const label = getMenuLabelFromFile(indexPath);
|
|
83
|
+
if (label) return label;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Fall back to config.json label (deprecated)
|
|
87
|
+
if (folderConfig?.label) {
|
|
88
|
+
return folderConfig.label;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Default to display name from folder name
|
|
92
|
+
return toDisplayName(baseName);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Get the sort key for a folder from its index.md frontmatter
|
|
97
|
+
* @param {string} dirPath - Path to the folder
|
|
98
|
+
* @returns {string|null} The menu-sort-as value, or null if not found
|
|
99
|
+
*/
|
|
100
|
+
export function getFolderSortKey(dirPath) {
|
|
101
|
+
for (const ext of INDEX_EXTENSIONS) {
|
|
102
|
+
const indexPath = join(dirPath, `index${ext}`);
|
|
103
|
+
const sortKey = getMenuSortAsFromFile(indexPath);
|
|
104
|
+
if (sortKey) return sortKey;
|
|
105
|
+
}
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Locate the source document that produced (or would produce) a page.
|
|
111
|
+
* Auto-index listings built from the OUTPUT folder only see "foo.html"; this
|
|
112
|
+
* finds the "foo.md" it came from so its frontmatter can be read.
|
|
113
|
+
* @param {string} dir - Source directory to look in
|
|
114
|
+
* @param {string} baseName - File name without extension
|
|
115
|
+
* @returns {string|null} Path to the source document, or null if none exists
|
|
116
|
+
*/
|
|
117
|
+
export function findSourceDocument(dir, baseName) {
|
|
118
|
+
if (!dir) return null;
|
|
119
|
+
for (const ext of SOURCE_DOC_EXTENSIONS) {
|
|
120
|
+
const candidate = join(dir, `${baseName}${ext}`);
|
|
121
|
+
if (existsSync(candidate)) return candidate;
|
|
122
|
+
}
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Get the menu label for a single document.
|
|
128
|
+
* @param {string|null} filePath - Path to the source document (may be null)
|
|
129
|
+
* @param {string} [baseName] - Fallback name; defaults to the file's base name
|
|
130
|
+
* @returns {string} The label to display
|
|
131
|
+
*/
|
|
132
|
+
export function getFileLabel(filePath, baseName) {
|
|
133
|
+
const fallback = baseName ?? (filePath ? basename(filePath, extname(filePath)) : '');
|
|
134
|
+
if (!filePath) return toDisplayName(fallback);
|
|
135
|
+
return getMenuLabelFromFile(filePath) || toDisplayName(fallback);
|
|
136
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "fs";
|
|
2
|
+
import { dirname, resolve } from "path";
|
|
3
|
+
import { fileURLToPath } from "url";
|
|
4
|
+
|
|
5
|
+
let cached = null;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Read ursa's own version from the package.json that ships with it.
|
|
9
|
+
* Memoised — it cannot change while the process runs.
|
|
10
|
+
* @returns {string} The version, or 'unknown' if it can't be read
|
|
11
|
+
*/
|
|
12
|
+
export function getUrsaVersion() {
|
|
13
|
+
if (cached) return cached;
|
|
14
|
+
try {
|
|
15
|
+
// From src/helper/ursaVersion.js, go up to the package root
|
|
16
|
+
const currentDir = dirname(fileURLToPath(import.meta.url));
|
|
17
|
+
const ursaPackagePath = resolve(currentDir, "..", "..", "package.json");
|
|
18
|
+
if (existsSync(ursaPackagePath)) {
|
|
19
|
+
const ursaPackage = JSON.parse(readFileSync(ursaPackagePath, "utf8"));
|
|
20
|
+
if (ursaPackage.version) return (cached = ursaPackage.version);
|
|
21
|
+
}
|
|
22
|
+
} catch (e) {
|
|
23
|
+
console.error(`Error reading ursa package.json: ${e.message}`);
|
|
24
|
+
}
|
|
25
|
+
return (cached = "unknown");
|
|
26
|
+
}
|
package/src/jobs/generate.js
CHANGED
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
outputsExist,
|
|
22
22
|
updateHash,
|
|
23
23
|
getUrsaDir,
|
|
24
|
+
enforceCacheVersion,
|
|
24
25
|
} from "../helper/contentHash.js";
|
|
25
26
|
import {
|
|
26
27
|
buildValidPaths,
|
|
@@ -168,7 +169,20 @@ export async function generate({
|
|
|
168
169
|
progress.logTimed(`Clean build: clearing output directory ${output}`);
|
|
169
170
|
await emptyDir(output);
|
|
170
171
|
progress.logTimed(`Clean complete [${progress.stopTimer('Clean')}]`);
|
|
171
|
-
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Stamp the cache with ursa's version, discarding it if a different ursa
|
|
175
|
+
// wrote it. Hash-skipping only compares source content, so without this an
|
|
176
|
+
// upgrade leaves every unchanged document frozen at whatever the previous
|
|
177
|
+
// version's templates and renderers produced.
|
|
178
|
+
const cacheStamp = await enforceCacheVersion(source);
|
|
179
|
+
if (cacheStamp.reset) {
|
|
180
|
+
progress.logTimed(
|
|
181
|
+
`Cache discarded: written by ursa ${cacheStamp.previous ?? '(unstamped)'}, now running ${cacheStamp.version}`
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (!_clean) {
|
|
172
186
|
// Warm start: reload persisted dependency registrations so hash-skipped
|
|
173
187
|
// documents keep their edges (current-run registrations take precedence)
|
|
174
188
|
const loaded = await loadDependencyTracker(source);
|
|
@@ -734,7 +748,7 @@ export async function generate({
|
|
|
734
748
|
}
|
|
735
749
|
|
|
736
750
|
// Inject breadcrumbs before the H1
|
|
737
|
-
const breadcrumbs = generateBreadcrumbs(dir, base, fileMeta);
|
|
751
|
+
const breadcrumbs = generateBreadcrumbs(dir, base, fileMeta, source);
|
|
738
752
|
if (breadcrumbs) {
|
|
739
753
|
body = breadcrumbs + body;
|
|
740
754
|
}
|
|
@@ -1552,7 +1566,7 @@ export async function regenerateSingleFile(changedFile, {
|
|
|
1552
1566
|
}
|
|
1553
1567
|
|
|
1554
1568
|
// Inject breadcrumbs before the H1
|
|
1555
|
-
const breadcrumbs = generateBreadcrumbs(dir, base, fileMeta);
|
|
1569
|
+
const breadcrumbs = generateBreadcrumbs(dir, base, fileMeta, source);
|
|
1556
1570
|
if (breadcrumbs) {
|
|
1557
1571
|
body = breadcrumbs + body;
|
|
1558
1572
|
}
|