@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.
@@ -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
+ });