@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
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
// Template helpers for build
|
|
2
|
-
import { readFile, readdir } from "
|
|
2
|
+
import { readFile, readdir, existsSync } from "./tracedFs.js";
|
|
3
3
|
import { join, basename } from "path";
|
|
4
|
-
import { existsSync } from "fs";
|
|
5
4
|
|
|
6
5
|
/**
|
|
7
6
|
* Get all templates from meta directory
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Filesystem access that records what it touched.
|
|
3
|
+
*
|
|
4
|
+
* The build graph (graph.js) decides what to rebuild purely from recorded
|
|
5
|
+
* inputs, so every file a node's compute function reads, every path it probes
|
|
6
|
+
* and every directory it lists has to become an edge. Threading a context
|
|
7
|
+
* object through every helper that touches the disk — the menu walker, the
|
|
8
|
+
* label resolver, the breadcrumb builder, the style.css chain finder — would
|
|
9
|
+
* mean rewriting all of them. Instead those helpers import their `fs` calls
|
|
10
|
+
* from here. When a node is computing, an AsyncLocalStorage store carries that
|
|
11
|
+
* node's recorder, and each call below reports the leaf it observed. Outside a
|
|
12
|
+
* compute (tests, the CLI) the calls are plain `fs`.
|
|
13
|
+
*
|
|
14
|
+
* Because AsyncLocalStorage follows the async chain, concurrent computes each
|
|
15
|
+
* see only their own recorder, and a synchronous read inside a deeply nested
|
|
16
|
+
* helper is attributed to the right node.
|
|
17
|
+
*
|
|
18
|
+
* Fingerprints:
|
|
19
|
+
* - file: md5 of the content (16 hex chars), or "missing"
|
|
20
|
+
* - lookup: "exists" | "absent"
|
|
21
|
+
* - dir: md5 of the sorted visible child names, or "missing"
|
|
22
|
+
*
|
|
23
|
+
* Names beginning with "." and the deny-listed names (`node_modules`, the
|
|
24
|
+
* `4913` vim probe, editor scratch files) are not part of a directory's
|
|
25
|
+
* fingerprint — they are never build inputs, and excluding them keeps an
|
|
26
|
+
* editor's temp files from dirtying the menu.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { AsyncLocalStorage } from "async_hooks";
|
|
30
|
+
import { createHash } from "crypto";
|
|
31
|
+
import fs from "fs";
|
|
32
|
+
import fsp from "fs/promises";
|
|
33
|
+
|
|
34
|
+
const als = new AsyncLocalStorage();
|
|
35
|
+
|
|
36
|
+
export const MISSING = "missing";
|
|
37
|
+
export const EXISTS = "exists";
|
|
38
|
+
export const ABSENT = "absent";
|
|
39
|
+
|
|
40
|
+
export function hashBytes(data) {
|
|
41
|
+
return createHash("md5").update(data).digest("hex").substring(0, 16);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Run `fn` with `recorder` as the active leaf recorder. */
|
|
45
|
+
export function withRecorder(recorder, fn) {
|
|
46
|
+
return als.run(recorder, fn);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The recorder for the currently computing node, or undefined. */
|
|
50
|
+
export function currentRecorder() {
|
|
51
|
+
return als.getStore();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// Directory listings
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
/** Editor scratch and probe files that must never count as inputs. */
|
|
59
|
+
export function isScratchName(name) {
|
|
60
|
+
return (
|
|
61
|
+
name.endsWith("~") ||
|
|
62
|
+
name.endsWith(".swp") ||
|
|
63
|
+
name.endsWith(".swx") ||
|
|
64
|
+
name.endsWith(".tmp") ||
|
|
65
|
+
name.startsWith(".#") ||
|
|
66
|
+
name === "4913"
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Names never observed by a directory leaf. */
|
|
71
|
+
export function isIgnoredDirEntry(name) {
|
|
72
|
+
return name.startsWith(".") || name === "node_modules" || isScratchName(name);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Fingerprint a directory from its Dirent list (or `null` when it is missing).
|
|
77
|
+
* Sorted by name so readdir order never leaks into a fingerprint.
|
|
78
|
+
*/
|
|
79
|
+
export function dirFingerprintFromEntries(entries) {
|
|
80
|
+
if (!entries) return MISSING;
|
|
81
|
+
const names = [];
|
|
82
|
+
for (const e of entries) {
|
|
83
|
+
const name = typeof e === "string" ? e : e.name;
|
|
84
|
+
if (isIgnoredDirEntry(name)) continue;
|
|
85
|
+
// A directory and a file of the same name are different inputs
|
|
86
|
+
names.push(typeof e !== "string" && e.isDirectory() ? name + "/" : name);
|
|
87
|
+
}
|
|
88
|
+
names.sort();
|
|
89
|
+
return hashBytes(names.join("\n"));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
// Recording helpers (shared by sync and async variants)
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
function recordFile(rec, path, st, buf) {
|
|
97
|
+
if (!rec) return;
|
|
98
|
+
if (!st) {
|
|
99
|
+
rec.file(path, MISSING, null);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
const stats = { size: st.size, mtimeMs: st.mtimeMs };
|
|
103
|
+
const fp = rec.knownFileFingerprint?.(path, stats) ?? hashBytes(buf);
|
|
104
|
+
rec.file(path, fp, stats);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function recordLookup(rec, path, exists) {
|
|
108
|
+
if (rec) rec.lookup(path, exists ? EXISTS : ABSENT);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function recordDir(rec, path, entries) {
|
|
112
|
+
if (rec) rec.dir(path, dirFingerprintFromEntries(entries));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
// Synchronous API
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
export function existsSync(path) {
|
|
120
|
+
const exists = fs.existsSync(path);
|
|
121
|
+
recordLookup(currentRecorder(), path, exists);
|
|
122
|
+
return exists;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function readFileSync(path, options) {
|
|
126
|
+
const rec = currentRecorder();
|
|
127
|
+
let st = null;
|
|
128
|
+
try {
|
|
129
|
+
st = fs.statSync(path);
|
|
130
|
+
} catch {
|
|
131
|
+
recordFile(rec, path, null, null);
|
|
132
|
+
throw enoent(path);
|
|
133
|
+
}
|
|
134
|
+
const buf = fs.readFileSync(path);
|
|
135
|
+
recordFile(rec, path, st, buf);
|
|
136
|
+
return options ? buf.toString(typeof options === "string" ? options : options.encoding ?? "utf8") : buf;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function readdirSync(path, options) {
|
|
140
|
+
const rec = currentRecorder();
|
|
141
|
+
let entries;
|
|
142
|
+
try {
|
|
143
|
+
entries = fs.readdirSync(path, { withFileTypes: true });
|
|
144
|
+
} catch (e) {
|
|
145
|
+
recordDir(rec, path, null);
|
|
146
|
+
throw e;
|
|
147
|
+
}
|
|
148
|
+
recordDir(rec, path, entries);
|
|
149
|
+
if (options?.withFileTypes) return entries;
|
|
150
|
+
return entries.map((e) => e.name);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** stat as an existence probe plus a content leaf: callers use size/mtime. */
|
|
154
|
+
export function statSync(path) {
|
|
155
|
+
const rec = currentRecorder();
|
|
156
|
+
try {
|
|
157
|
+
const st = fs.statSync(path);
|
|
158
|
+
recordLookup(rec, path, true);
|
|
159
|
+
return st;
|
|
160
|
+
} catch (e) {
|
|
161
|
+
recordLookup(rec, path, false);
|
|
162
|
+
throw e;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
// Promise API
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
export async function readFile(path, options) {
|
|
171
|
+
const rec = currentRecorder();
|
|
172
|
+
let st = null;
|
|
173
|
+
try {
|
|
174
|
+
st = await fsp.stat(path);
|
|
175
|
+
} catch {
|
|
176
|
+
recordFile(rec, path, null, null);
|
|
177
|
+
throw enoent(path);
|
|
178
|
+
}
|
|
179
|
+
const buf = await fsp.readFile(path);
|
|
180
|
+
recordFile(rec, path, st, buf);
|
|
181
|
+
return options ? buf.toString(typeof options === "string" ? options : options.encoding ?? "utf8") : buf;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export async function readdir(path, options) {
|
|
185
|
+
const rec = currentRecorder();
|
|
186
|
+
let entries;
|
|
187
|
+
try {
|
|
188
|
+
entries = await fsp.readdir(path, { withFileTypes: true });
|
|
189
|
+
} catch (e) {
|
|
190
|
+
recordDir(rec, path, null);
|
|
191
|
+
throw e;
|
|
192
|
+
}
|
|
193
|
+
recordDir(rec, path, entries);
|
|
194
|
+
if (options?.withFileTypes) return entries;
|
|
195
|
+
return entries.map((e) => e.name);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export async function stat(path) {
|
|
199
|
+
const rec = currentRecorder();
|
|
200
|
+
try {
|
|
201
|
+
const st = await fsp.stat(path);
|
|
202
|
+
recordLookup(rec, path, true);
|
|
203
|
+
return st;
|
|
204
|
+
} catch (e) {
|
|
205
|
+
recordLookup(rec, path, false);
|
|
206
|
+
throw e;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export async function exists(path) {
|
|
211
|
+
try {
|
|
212
|
+
await fsp.access(path);
|
|
213
|
+
recordLookup(currentRecorder(), path, true);
|
|
214
|
+
return true;
|
|
215
|
+
} catch {
|
|
216
|
+
recordLookup(currentRecorder(), path, false);
|
|
217
|
+
return false;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Directory entries that are build inputs, sorted by name, with `kind`.
|
|
223
|
+
* Returns [] for a missing directory (the miss is recorded).
|
|
224
|
+
* @returns {Promise<{name: string, kind: 'file'|'dir'|'other'}[]>}
|
|
225
|
+
*/
|
|
226
|
+
export async function listDir(path) {
|
|
227
|
+
let entries;
|
|
228
|
+
try {
|
|
229
|
+
entries = await readdir(path, { withFileTypes: true });
|
|
230
|
+
} catch {
|
|
231
|
+
return [];
|
|
232
|
+
}
|
|
233
|
+
return entries
|
|
234
|
+
.filter((e) => !isIgnoredDirEntry(e.name))
|
|
235
|
+
.map((e) => ({
|
|
236
|
+
name: e.name,
|
|
237
|
+
kind: e.isDirectory() ? "dir" : e.isFile() ? "file" : "other",
|
|
238
|
+
}))
|
|
239
|
+
.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function enoent(path) {
|
|
243
|
+
const e = new Error(`ENOENT: no such file or directory, open '${path}'`);
|
|
244
|
+
e.code = "ENOENT";
|
|
245
|
+
e.path = path;
|
|
246
|
+
return e;
|
|
247
|
+
}
|
|
@@ -1,11 +1,9 @@
|
|
|
1
|
-
import { createHash } from 'crypto';
|
|
2
1
|
import { readFile, writeFile, mkdir, rm } from 'fs/promises';
|
|
3
2
|
import { existsSync } from 'fs';
|
|
4
3
|
import { dirname, join } from 'path';
|
|
5
4
|
import { getUrsaVersion } from './ursaVersion.js';
|
|
6
5
|
|
|
7
6
|
const URSA_DIR = '.ursa';
|
|
8
|
-
const HASH_CACHE_FILE = 'content-hashes.json';
|
|
9
7
|
const CACHE_STAMP_FILE = 'cache-stamp.json';
|
|
10
8
|
|
|
11
9
|
/**
|
|
@@ -65,79 +63,3 @@ export async function enforceCacheVersion(sourceDir, version = getUrsaVersion())
|
|
|
65
63
|
|
|
66
64
|
return { reset: hadCache, previous, version };
|
|
67
65
|
}
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* Generate a short hash of content
|
|
71
|
-
*/
|
|
72
|
-
export function hashContent(content) {
|
|
73
|
-
return createHash('md5').update(content).digest('hex').substring(0, 12);
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/**
|
|
77
|
-
* Load the hash cache from disk (.ursa folder in source directory)
|
|
78
|
-
*/
|
|
79
|
-
export async function loadHashCache(sourceDir) {
|
|
80
|
-
const cachePath = join(getUrsaDir(sourceDir), HASH_CACHE_FILE);
|
|
81
|
-
try {
|
|
82
|
-
if (existsSync(cachePath)) {
|
|
83
|
-
const data = await readFile(cachePath, 'utf8');
|
|
84
|
-
return new Map(Object.entries(JSON.parse(data)));
|
|
85
|
-
}
|
|
86
|
-
} catch (e) {
|
|
87
|
-
console.warn('Could not load hash cache:', e.message);
|
|
88
|
-
}
|
|
89
|
-
return new Map();
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* Save the hash cache to disk (.ursa folder in source directory)
|
|
94
|
-
*/
|
|
95
|
-
export async function saveHashCache(sourceDir, hashMap) {
|
|
96
|
-
const ursaDir = getUrsaDir(sourceDir);
|
|
97
|
-
const cachePath = join(ursaDir, HASH_CACHE_FILE);
|
|
98
|
-
try {
|
|
99
|
-
await mkdir(ursaDir, { recursive: true });
|
|
100
|
-
const obj = Object.fromEntries(hashMap);
|
|
101
|
-
await writeFile(cachePath, JSON.stringify(obj, null, 2));
|
|
102
|
-
console.log(`Saved ${hashMap.size} hashes to ${cachePath}`);
|
|
103
|
-
} catch (e) {
|
|
104
|
-
console.warn('Could not save hash cache:', e.message);
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/**
|
|
109
|
-
* Check if a file needs regeneration based on content hash
|
|
110
|
-
*/
|
|
111
|
-
export function needsRegeneration(filePath, content, hashCache) {
|
|
112
|
-
const newHash = hashContent(content);
|
|
113
|
-
const oldHash = hashCache.get(filePath);
|
|
114
|
-
return newHash !== oldHash;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
/**
|
|
118
|
-
* Check whether every expected output file for a source document exists.
|
|
119
|
-
*
|
|
120
|
-
* A matching content hash only proves the *source* is unchanged — it says
|
|
121
|
-
* nothing about whether the output was ever written to this particular output
|
|
122
|
-
* directory. The hash cache lives in the source tree (`<source>/.ursa/`) and is
|
|
123
|
-
* shared by every output directory built from that source, so a hash written
|
|
124
|
-
* during a build to one output dir will hash-skip the same file during a build
|
|
125
|
-
* to another. Deleting (or partially losing) an output dir has the same effect.
|
|
126
|
-
* Callers must combine this with needsRegeneration() so a missing output always
|
|
127
|
-
* forces a rebuild.
|
|
128
|
-
*
|
|
129
|
-
* @param {string[]} outputPaths - Absolute paths to every file the build emits for this document
|
|
130
|
-
* @returns {boolean} True only if all of them are present
|
|
131
|
-
*/
|
|
132
|
-
export function outputsExist(outputPaths) {
|
|
133
|
-
return outputPaths.every((p) => existsSync(p));
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
/**
|
|
137
|
-
* Update the hash for a file in the cache
|
|
138
|
-
*/
|
|
139
|
-
export function updateHash(filePath, content, hashCache) {
|
|
140
|
-
const hash = hashContent(content);
|
|
141
|
-
hashCache.set(filePath, hash);
|
|
142
|
-
return hash;
|
|
143
|
-
}
|
package/src/helper/customMenu.js
CHANGED
|
@@ -1,11 +1,30 @@
|
|
|
1
|
-
// Custom menu support - allows defining custom menus in menu.md, menu.txt, _menu.md, or _menu.txt
|
|
2
|
-
|
|
1
|
+
// Custom menu support - allows defining custom menus in menu.md, menu.txt, _menu.md, or _menu.txt.
|
|
2
|
+
// Named menus (menu-<name>.md with an `id`) are handled by inlineMenu.js.
|
|
3
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "./build/tracedFs.js";
|
|
3
4
|
import { join, dirname, relative, resolve, basename, extname } from "path";
|
|
4
5
|
import { extractMetadata } from "./metadataExtractor.js";
|
|
5
6
|
|
|
6
7
|
// Menu file names to look for (in order of priority)
|
|
7
8
|
const MENU_FILE_NAMES = ['menu.md', 'menu.txt', '_menu.md', '_menu.txt'];
|
|
8
9
|
|
|
10
|
+
/**
|
|
11
|
+
* Every menu file: the folder menu (`menu.md`, `_menu.md`, `.txt` variants)
|
|
12
|
+
* and the named menus (`menu-classes.md`, `menu-2.txt`, …) that inlineMenu.js
|
|
13
|
+
* renders where a document anchors them. None of these is a document.
|
|
14
|
+
*/
|
|
15
|
+
export const MENU_FILE_RE = /^_?menu(-[a-z0-9][a-z0-9_.-]*)?\.(md|txt)$/i;
|
|
16
|
+
|
|
17
|
+
/** True when a basename names a menu file (folder menu or named menu). */
|
|
18
|
+
export function isMenuFile(name) {
|
|
19
|
+
return MENU_FILE_RE.test(name);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** True when a menu file's frontmatter makes it a named menu (rendered only by anchors). */
|
|
23
|
+
export function isNamedMenuFrontmatter(frontmatter) {
|
|
24
|
+
const id = frontmatter?.id;
|
|
25
|
+
return id !== undefined && id !== null && String(id).trim() !== '';
|
|
26
|
+
}
|
|
27
|
+
|
|
9
28
|
// Token to mark where auto-generated menu should be inserted
|
|
10
29
|
const MENU_TOKEN = '{menu}';
|
|
11
30
|
|
|
@@ -115,6 +134,7 @@ function folderHasDocuments(dirPath) {
|
|
|
115
134
|
if (entry.name === 'img') continue;
|
|
116
135
|
if (folderHasDocuments(fullPath)) return true;
|
|
117
136
|
} else {
|
|
137
|
+
if (isMenuFile(entry.name)) continue;
|
|
118
138
|
const ext = extname(entry.name);
|
|
119
139
|
if (SOURCE_EXTENSIONS.includes(ext)) return true;
|
|
120
140
|
}
|
|
@@ -236,8 +256,8 @@ export function autoGenerateMenuFromFolder(folderPath, sourceRoot, depth = 10, i
|
|
|
236
256
|
for (const entry of entries) {
|
|
237
257
|
// Skip hidden files/folders
|
|
238
258
|
if (entry.name.startsWith('.') || entry.name.startsWith('_')) continue;
|
|
239
|
-
// Skip menu files themselves
|
|
240
|
-
if (
|
|
259
|
+
// Skip menu files themselves (the folder menu and any named menus)
|
|
260
|
+
if (isMenuFile(entry.name)) continue;
|
|
241
261
|
// Skip config files
|
|
242
262
|
if (entry.name === 'config.json') continue;
|
|
243
263
|
// Skip img folders
|
|
@@ -406,6 +426,9 @@ export function findCustomMenu(dirPath, sourceRoot) {
|
|
|
406
426
|
if (existsSync(menuPath)) {
|
|
407
427
|
try {
|
|
408
428
|
const content = readFileSync(menuPath, 'utf8');
|
|
429
|
+
// A menu.md with an `id` is a named menu: it renders only where a
|
|
430
|
+
// document anchors it, and does not replace the folder's nav
|
|
431
|
+
if (isNamedMenuFrontmatter(extractMenuFrontmatter(content).frontmatter)) continue;
|
|
409
432
|
return {
|
|
410
433
|
path: menuPath,
|
|
411
434
|
content,
|
|
@@ -1,112 +1,120 @@
|
|
|
1
|
-
|
|
2
|
-
import { markdownToHtml } from "./markdownHelper.cjs";
|
|
3
|
-
import { wikiToHtml } from "./wikitextHelper.js";
|
|
4
|
-
import { renderMDX, generateHydrationScript } from "./mdxRenderer.js";
|
|
5
|
-
import { parseWithWorker, terminateParserPool } from "./parserPool.js";
|
|
6
|
-
|
|
7
|
-
const DEFAULT_WIKITEXT_ARGS = { db: "noDB", noSection: true, noTOC: true };
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* Render a file synchronously (legacy/fallback)
|
|
11
|
-
* Note: .mdx files require async rendering; use renderFileAsync instead.
|
|
12
|
-
* @param {Object} options - Render options
|
|
13
|
-
* @param {string} options.fileContents - Raw file content
|
|
14
|
-
* @param {string} options.type - File extension (.md, .txt)
|
|
15
|
-
* @param {string} options.dirname - Directory name
|
|
16
|
-
* @param {string} options.basename - Base filename
|
|
17
|
-
* @returns {string} Rendered HTML
|
|
18
|
-
*/
|
|
19
|
-
export function renderFile({ fileContents, type, dirname, basename }) {
|
|
20
|
-
switch (type) {
|
|
21
|
-
case ".md":
|
|
22
|
-
return markdownToHtml(fileContents);
|
|
23
|
-
case ".txt":
|
|
24
|
-
return wikiToHtml({
|
|
25
|
-
wikitext: fileContents,
|
|
26
|
-
articleName: basename,
|
|
27
|
-
args: { ...DEFAULT_WIKITEXT_ARGS, db: dirname },
|
|
28
|
-
})?.html;
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Render a file asynchronously using worker threads for parallel processing
|
|
34
|
-
* Falls back to main thread for wikitext or if workers are unavailable
|
|
35
|
-
* @param {Object} options - Render options
|
|
36
|
-
* @param {string} options.fileContents - Raw file content
|
|
37
|
-
* @param {string} options.type - File extension (.md, .txt, or .mdx)
|
|
38
|
-
* @param {string} options.dirname - Directory name
|
|
39
|
-
* @param {string} options.basename - Base filename
|
|
40
|
-
* @param {string} [options.filePath] - Absolute path to file (required for .mdx)
|
|
41
|
-
* @param {string} [options.sourceRoot] - Source root directory (for .mdx absolute imports)
|
|
42
|
-
* @param {boolean} [options.useWorker=true] - Whether to attempt worker thread parsing
|
|
43
|
-
* @param {boolean} [options.hydrate=false] - Whether to enable client-side hydration (.mdx only)
|
|
44
|
-
* @returns {Promise<string|{html: string, hydrationScript?: string}>} Rendered HTML or object with HTML and hydration script
|
|
45
|
-
*/
|
|
46
|
-
export async function renderFileAsync({ fileContents, type, dirname, basename, filePath, sourceRoot, useWorker = true, hydrate = false }) {
|
|
47
|
-
// Wikitext always runs on main thread due to complex ES module dependencies
|
|
48
|
-
if (type === ".txt") {
|
|
49
|
-
return wikiToHtml({
|
|
50
|
-
wikitext: fileContents,
|
|
51
|
-
articleName: basename,
|
|
52
|
-
args: { ...DEFAULT_WIKITEXT_ARGS, db: dirname },
|
|
53
|
-
})?.html;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
// Markdown can use worker threads
|
|
57
|
-
if (type === ".md") {
|
|
58
|
-
if (useWorker) {
|
|
59
|
-
return parseWithWorker(
|
|
60
|
-
fileContents,
|
|
61
|
-
type,
|
|
62
|
-
dirname,
|
|
63
|
-
basename,
|
|
64
|
-
() => markdownToHtml(fileContents) // Fallback to main thread
|
|
65
|
-
);
|
|
66
|
-
}
|
|
67
|
-
return markdownToHtml(fileContents);
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
// MDX uses mdx-bundler + React SSR (always async, no worker support)
|
|
71
|
-
// Falls back to markdown rendering if MDX compilation fails
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
const
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
console.warn(
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
+ `<
|
|
103
|
-
+
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
1
|
+
|
|
2
|
+
import { markdownToHtml } from "./markdownHelper.cjs";
|
|
3
|
+
import { wikiToHtml } from "./wikitextHelper.js";
|
|
4
|
+
import { renderMDX, generateHydrationScript } from "./mdxRenderer.js";
|
|
5
|
+
import { parseWithWorker, terminateParserPool } from "./parserPool.js";
|
|
6
|
+
|
|
7
|
+
const DEFAULT_WIKITEXT_ARGS = { db: "noDB", noSection: true, noTOC: true };
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Render a file synchronously (legacy/fallback)
|
|
11
|
+
* Note: .mdx files require async rendering; use renderFileAsync instead.
|
|
12
|
+
* @param {Object} options - Render options
|
|
13
|
+
* @param {string} options.fileContents - Raw file content
|
|
14
|
+
* @param {string} options.type - File extension (.md, .txt)
|
|
15
|
+
* @param {string} options.dirname - Directory name
|
|
16
|
+
* @param {string} options.basename - Base filename
|
|
17
|
+
* @returns {string} Rendered HTML
|
|
18
|
+
*/
|
|
19
|
+
export function renderFile({ fileContents, type, dirname, basename }) {
|
|
20
|
+
switch (type) {
|
|
21
|
+
case ".md":
|
|
22
|
+
return markdownToHtml(fileContents);
|
|
23
|
+
case ".txt":
|
|
24
|
+
return wikiToHtml({
|
|
25
|
+
wikitext: fileContents,
|
|
26
|
+
articleName: basename,
|
|
27
|
+
args: { ...DEFAULT_WIKITEXT_ARGS, db: dirname },
|
|
28
|
+
})?.html;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Render a file asynchronously using worker threads for parallel processing
|
|
34
|
+
* Falls back to main thread for wikitext or if workers are unavailable
|
|
35
|
+
* @param {Object} options - Render options
|
|
36
|
+
* @param {string} options.fileContents - Raw file content
|
|
37
|
+
* @param {string} options.type - File extension (.md, .txt, or .mdx)
|
|
38
|
+
* @param {string} options.dirname - Directory name
|
|
39
|
+
* @param {string} options.basename - Base filename
|
|
40
|
+
* @param {string} [options.filePath] - Absolute path to file (required for .mdx)
|
|
41
|
+
* @param {string} [options.sourceRoot] - Source root directory (for .mdx absolute imports)
|
|
42
|
+
* @param {boolean} [options.useWorker=true] - Whether to attempt worker thread parsing
|
|
43
|
+
* @param {boolean} [options.hydrate=false] - Whether to enable client-side hydration (.mdx only)
|
|
44
|
+
* @returns {Promise<string|{html: string, hydrationScript?: string}>} Rendered HTML or object with HTML and hydration script
|
|
45
|
+
*/
|
|
46
|
+
export async function renderFileAsync({ fileContents, type, dirname, basename, filePath, sourceRoot, useWorker = true, hydrate = false }) {
|
|
47
|
+
// Wikitext always runs on main thread due to complex ES module dependencies
|
|
48
|
+
if (type === ".txt") {
|
|
49
|
+
return wikiToHtml({
|
|
50
|
+
wikitext: fileContents,
|
|
51
|
+
articleName: basename,
|
|
52
|
+
args: { ...DEFAULT_WIKITEXT_ARGS, db: dirname },
|
|
53
|
+
})?.html;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Markdown can use worker threads
|
|
57
|
+
if (type === ".md") {
|
|
58
|
+
if (useWorker) {
|
|
59
|
+
return parseWithWorker(
|
|
60
|
+
fileContents,
|
|
61
|
+
type,
|
|
62
|
+
dirname,
|
|
63
|
+
basename,
|
|
64
|
+
() => markdownToHtml(fileContents) // Fallback to main thread
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
return markdownToHtml(fileContents);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// MDX uses mdx-bundler + React SSR (always async, no worker support)
|
|
71
|
+
// Falls back to markdown rendering if MDX compilation fails.
|
|
72
|
+
// Always returns the object form: { html, hydrationScript, inputs, failed }.
|
|
73
|
+
// `inputs` lists the module files the bundle loaded; `failed` is set when the
|
|
74
|
+
// fallback rendered, so callers know the dependency list is incomplete.
|
|
75
|
+
if (type === ".mdx") {
|
|
76
|
+
try {
|
|
77
|
+
const result = await renderMDX({ source: fileContents, filePath, sourceRoot, hydrate });
|
|
78
|
+
|
|
79
|
+
// If hydration was requested and we have client code, include the hydration script
|
|
80
|
+
return {
|
|
81
|
+
html: result.html,
|
|
82
|
+
hydrationScript: hydrate && result.clientCode ? generateHydrationScript(result.clientCode) : '',
|
|
83
|
+
inputs: result.inputs ?? [],
|
|
84
|
+
failed: false,
|
|
85
|
+
};
|
|
86
|
+
} catch (mdxError) {
|
|
87
|
+
// Extract a concise error description for the warning banner
|
|
88
|
+
const errorMsg = mdxError.message || String(mdxError);
|
|
89
|
+
const shortPath = filePath?.split('/').slice(-3).join('/') || 'unknown file';
|
|
90
|
+
|
|
91
|
+
// Extract the actionable part of the error (skip the "MDX compilation failed for <path>:" preamble)
|
|
92
|
+
const lines = errorMsg.split('\n').filter(l => l.trim());
|
|
93
|
+
const actionableLine = lines.length > 1 ? lines.slice(1).join(' ').trim() : lines[0];
|
|
94
|
+
const errorDetail = actionableLine.slice(0, 300);
|
|
95
|
+
|
|
96
|
+
console.warn(`⚠️ MDX compilation failed for ${shortPath}, falling back to Markdown rendering`);
|
|
97
|
+
console.warn(` ${errorDetail}`);
|
|
98
|
+
|
|
99
|
+
// Render as markdown instead (handles raw HTML fine, just no custom components)
|
|
100
|
+
const markdownHtml = markdownToHtml(fileContents);
|
|
101
|
+
const warningBanner = `<div style="background:#fef3cd;border:1px solid #ffc107;color:#856404;padding:0.75rem 1rem;margin-bottom:1rem;border-radius:4px;font-size:0.875rem;">`
|
|
102
|
+
+ `<strong>⚠️ MDX compilation error</strong> — this page was rendered as Markdown (custom components like <CharacterCard> will not appear).<br>`
|
|
103
|
+
+ `<code style="font-size:0.8rem;word-break:break-all;">${errorDetail.replace(/</g, '<').replace(/>/g, '>')}</code>`
|
|
104
|
+
+ `</div>`;
|
|
105
|
+
return {
|
|
106
|
+
html: warningBanner + markdownHtml,
|
|
107
|
+
hydrationScript: '',
|
|
108
|
+
inputs: mdxError.inputs ?? [],
|
|
109
|
+
componentDirs: mdxError.componentDirs ?? [],
|
|
110
|
+
failed: true,
|
|
111
|
+
error: errorDetail,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return undefined;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Re-export for cleanup on shutdown
|
|
112
120
|
export { terminateParserPool };
|