@kenjura/ursa 0.90.1 → 0.95.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +78 -0
- package/README.md +102 -0
- package/bin/ursa.js +14 -3
- package/meta/templates/default-template/default.css +1574 -1323
- package/meta/templates/default-template/index.html +175 -129
- package/meta/templates/default-template/lightbox.css +225 -216
- package/meta/templates/default-template/lightbox.js +3 -0
- package/meta/templates/default-template/menu.js +9 -6
- package/meta/templates/default-template/toc-generator.js +58 -0
- package/meta/templates/default-template/widgets.js +88 -15
- package/package.json +2 -1
- package/src/dev.js +30 -1
- package/src/helper/__test__/breadcrumbs.test.js +71 -0
- package/src/helper/__test__/contentHash.test.js +114 -0
- package/src/helper/__test__/folderConfig.test.js +89 -0
- package/src/helper/automenu.js +13 -91
- package/src/helper/breadcrumbs.js +21 -6
- package/src/helper/build/__test__/autoIndex.test.js +135 -1
- package/src/helper/build/autoIndex.js +115 -46
- package/src/helper/build/ursaMetadata.js +3 -26
- package/src/helper/contentHash.js +54 -1
- package/src/helper/folderConfig.js +34 -4
- package/src/helper/menuLabels.js +136 -0
- package/src/helper/ursaVersion.js +26 -0
- package/src/jobs/__test__/generateJsonOnly.test.js +154 -0
- package/src/jobs/generate.js +302 -181
- package/meta/default.css +0 -1206
- package/meta/menu.js +0 -898
- package/meta/sectionify.js +0 -46
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `generate({ _jsonOnly: true })` — emit the data files and nothing else.
|
|
3
|
+
*
|
|
4
|
+
* The load-bearing claim is not "fewer files": it is that the .json a JSON-only
|
|
5
|
+
* build writes is byte-identical to the one a full build writes. Every step the
|
|
6
|
+
* mode skips operates on the assembled page, never on the JSON. If that ever
|
|
7
|
+
* stops being true, `identical to a full build's JSON` fails here rather than
|
|
8
|
+
* silently shipping different data to a consumer.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { join } from "path";
|
|
12
|
+
import { mkdtemp, mkdir, writeFile, readFile, rm } from "fs/promises";
|
|
13
|
+
import { existsSync } from "fs";
|
|
14
|
+
import { tmpdir } from "os";
|
|
15
|
+
import { generate } from "../generate.js";
|
|
16
|
+
import { clearConfigCache } from "../../helper/folderConfig.js";
|
|
17
|
+
|
|
18
|
+
const META = join(process.cwd(), "meta");
|
|
19
|
+
|
|
20
|
+
let source;
|
|
21
|
+
let output;
|
|
22
|
+
|
|
23
|
+
async function doc(relPath, contents) {
|
|
24
|
+
const full = join(source, relPath);
|
|
25
|
+
await mkdir(join(full, ".."), { recursive: true });
|
|
26
|
+
await writeFile(full, contents);
|
|
27
|
+
return full;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
beforeEach(async () => {
|
|
31
|
+
source = await mkdtemp(join(tmpdir(), "ursa-jsononly-src-"));
|
|
32
|
+
output = await mkdtemp(join(tmpdir(), "ursa-jsononly-out-"));
|
|
33
|
+
clearConfigCache();
|
|
34
|
+
|
|
35
|
+
await doc("index.md", "# Home\n\nWelcome.\n");
|
|
36
|
+
await doc(
|
|
37
|
+
"character/powers/absorb-magic.md",
|
|
38
|
+
[
|
|
39
|
+
"---",
|
|
40
|
+
"class: Witch",
|
|
41
|
+
"name: Absorb Magic",
|
|
42
|
+
"school: Antimagic",
|
|
43
|
+
"brief: Absorb energy from a touched spell",
|
|
44
|
+
"---",
|
|
45
|
+
"",
|
|
46
|
+
"# Absorb Magic",
|
|
47
|
+
"",
|
|
48
|
+
"Touch a spell and take it apart.",
|
|
49
|
+
"",
|
|
50
|
+
"## Range",
|
|
51
|
+
"",
|
|
52
|
+
"Touch.",
|
|
53
|
+
"",
|
|
54
|
+
].join("\n")
|
|
55
|
+
);
|
|
56
|
+
await doc("character/powers/mind-blast.md", "# Mind Blast\n\nA blast, of the mind.\n");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
afterEach(async () => {
|
|
60
|
+
await rm(source, { recursive: true, force: true });
|
|
61
|
+
await rm(output, { recursive: true, force: true });
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const run = (opts) =>
|
|
65
|
+
generate({ _source: source, _meta: META, _output: output, _clean: true, ...opts });
|
|
66
|
+
|
|
67
|
+
describe("generate --json-only", () => {
|
|
68
|
+
it("writes the document JSON", async () => {
|
|
69
|
+
await run({ _jsonOnly: true });
|
|
70
|
+
expect(existsSync(join(output, "character/powers/absorb-magic.json"))).toBe(true);
|
|
71
|
+
expect(existsSync(join(output, "index.json"))).toBe(true);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("writes the directory record lists, which are the point of the mode", async () => {
|
|
75
|
+
await run({ _jsonOnly: true });
|
|
76
|
+
const listPath = join(output, "character/powers.json");
|
|
77
|
+
expect(existsSync(listPath)).toBe(true);
|
|
78
|
+
|
|
79
|
+
const records = JSON.parse(await readFile(listPath, "utf8"));
|
|
80
|
+
const absorb = records.find((r) => r.name === "absorb-magic");
|
|
81
|
+
expect(absorb).toBeDefined();
|
|
82
|
+
expect(absorb.url).toBe("/character/powers/absorb-magic.html");
|
|
83
|
+
expect(absorb.metadata.school).toBe("Antimagic");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("writes no HTML and no XML", async () => {
|
|
87
|
+
await run({ _jsonOnly: true });
|
|
88
|
+
expect(existsSync(join(output, "index.html"))).toBe(false);
|
|
89
|
+
expect(existsSync(join(output, "character/powers/absorb-magic.html"))).toBe(false);
|
|
90
|
+
expect(existsSync(join(output, "character/powers/absorb-magic.xml"))).toBe(false);
|
|
91
|
+
// The directory listing page, distinct from the record list above.
|
|
92
|
+
expect(existsSync(join(output, "character/powers.html"))).toBe(false);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("writes no meta assets, search index, menu data or recent activity", async () => {
|
|
96
|
+
await run({ _jsonOnly: true });
|
|
97
|
+
expect(existsSync(join(output, "public", "search-index.json"))).toBe(false);
|
|
98
|
+
expect(existsSync(join(output, "public", "fulltext-index.json"))).toBe(false);
|
|
99
|
+
expect(existsSync(join(output, "public", "menu-data.json"))).toBe(false);
|
|
100
|
+
expect(existsSync(join(output, "public", "recent-activity.json"))).toBe(false);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("produces JSON identical to a full build's", async () => {
|
|
104
|
+
await run({ _jsonOnly: true });
|
|
105
|
+
const jsonOnly = await readFile(
|
|
106
|
+
join(output, "character/powers/absorb-magic.json"),
|
|
107
|
+
"utf8"
|
|
108
|
+
);
|
|
109
|
+
const jsonOnlyList = await readFile(join(output, "character/powers.json"), "utf8");
|
|
110
|
+
|
|
111
|
+
await rm(output, { recursive: true, force: true });
|
|
112
|
+
await mkdir(output, { recursive: true });
|
|
113
|
+
await run({ _jsonOnly: false });
|
|
114
|
+
|
|
115
|
+
const full = await readFile(join(output, "character/powers/absorb-magic.json"), "utf8");
|
|
116
|
+
const fullList = await readFile(join(output, "character/powers.json"), "utf8");
|
|
117
|
+
|
|
118
|
+
expect(jsonOnly).toBe(full);
|
|
119
|
+
expect(jsonOnlyList).toBe(fullList);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("still emits everything on a normal build", async () => {
|
|
123
|
+
await run({ _jsonOnly: false });
|
|
124
|
+
expect(existsSync(join(output, "character/powers/absorb-magic.html"))).toBe(true);
|
|
125
|
+
expect(existsSync(join(output, "character/powers/absorb-magic.xml"))).toBe(true);
|
|
126
|
+
expect(existsSync(join(output, "public", "menu-data.json"))).toBe(true);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe("mixing modes against one source tree", () => {
|
|
131
|
+
// The hash cache lives in the SOURCE tree and is shared by both modes, so the
|
|
132
|
+
// per-document output check has to be mode-aware or one mode's cache entries
|
|
133
|
+
// would convince the other that its own missing outputs are up to date.
|
|
134
|
+
|
|
135
|
+
it("a full build after a JSON-only build still writes the HTML", async () => {
|
|
136
|
+
await run({ _jsonOnly: true });
|
|
137
|
+
expect(existsSync(join(output, "character/powers/absorb-magic.html"))).toBe(false);
|
|
138
|
+
|
|
139
|
+
// Warm: no --clean, so the hash cache from the JSON-only run is in play.
|
|
140
|
+
await generate({ _source: source, _meta: META, _output: output, _jsonOnly: false });
|
|
141
|
+
expect(existsSync(join(output, "character/powers/absorb-magic.html"))).toBe(true);
|
|
142
|
+
expect(existsSync(join(output, "character/powers/absorb-magic.xml"))).toBe(true);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("a JSON-only build after a full build leaves the JSON in place", async () => {
|
|
146
|
+
await run({ _jsonOnly: false });
|
|
147
|
+
const before = await readFile(join(output, "character/powers/absorb-magic.json"), "utf8");
|
|
148
|
+
|
|
149
|
+
await generate({ _source: source, _meta: META, _output: output, _jsonOnly: true });
|
|
150
|
+
const after = await readFile(join(output, "character/powers/absorb-magic.json"), "utf8");
|
|
151
|
+
|
|
152
|
+
expect(after).toBe(before);
|
|
153
|
+
});
|
|
154
|
+
});
|