@kenjura/ursa 0.96.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 +26 -0
- package/README.md +72 -16
- package/bin/ursa.js +14 -1
- package/meta/templates/default-template/menu.js +18 -1
- package/meta/templates/default-template/search.js +11 -0
- 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/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 +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,5 +1,5 @@
|
|
|
1
1
|
// Custom menu support - allows defining custom menus in menu.md, menu.txt, _menu.md, or _menu.txt
|
|
2
|
-
import { existsSync, readFileSync, readdirSync, statSync } from "
|
|
2
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "./build/tracedFs.js";
|
|
3
3
|
import { join, dirname, relative, resolve, basename, extname } from "path";
|
|
4
4
|
import { extractMetadata } from "./metadataExtractor.js";
|
|
5
5
|
|
|
@@ -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 };
|
|
@@ -1,11 +1,8 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from '
|
|
1
|
+
import { existsSync, readFileSync } from './build/tracedFs.js';
|
|
2
2
|
import { join, dirname } from 'path';
|
|
3
3
|
|
|
4
4
|
const CONFIG_FILENAME = 'config.json';
|
|
5
5
|
|
|
6
|
-
// Cache for folder configs to avoid repeated file reads
|
|
7
|
-
const configCache = new Map();
|
|
8
|
-
|
|
9
6
|
/**
|
|
10
7
|
* Folder configuration schema:
|
|
11
8
|
* {
|
|
@@ -24,11 +21,12 @@ const configCache = new Map();
|
|
|
24
21
|
*/
|
|
25
22
|
|
|
26
23
|
/**
|
|
27
|
-
*
|
|
24
|
+
* Kept for callers that used to reset the per-run cache. There is no cache
|
|
25
|
+
* any more: every read goes to the (traced) filesystem so that the build
|
|
26
|
+
* graph records config.json as an input of whatever consulted it. A cached
|
|
27
|
+
* hit would record nothing, and a `hidden: true` flip would go unnoticed.
|
|
28
28
|
*/
|
|
29
|
-
export function clearConfigCache() {
|
|
30
|
-
configCache.clear();
|
|
31
|
-
}
|
|
29
|
+
export function clearConfigCache() {}
|
|
32
30
|
|
|
33
31
|
/**
|
|
34
32
|
* Read and parse a folder's config.json if it exists (synchronous)
|
|
@@ -36,24 +34,15 @@ export function clearConfigCache() {
|
|
|
36
34
|
* @returns {object|null} Parsed config object or null if not found
|
|
37
35
|
*/
|
|
38
36
|
export function getFolderConfig(folderPath) {
|
|
39
|
-
// Check cache first
|
|
40
|
-
if (configCache.has(folderPath)) {
|
|
41
|
-
return configCache.get(folderPath);
|
|
42
|
-
}
|
|
43
|
-
|
|
44
37
|
const configPath = join(folderPath, CONFIG_FILENAME);
|
|
45
38
|
try {
|
|
46
39
|
if (existsSync(configPath)) {
|
|
47
40
|
const content = readFileSync(configPath, 'utf8');
|
|
48
|
-
|
|
49
|
-
configCache.set(folderPath, config);
|
|
50
|
-
return config;
|
|
41
|
+
return JSON.parse(content);
|
|
51
42
|
}
|
|
52
43
|
} catch (e) {
|
|
53
44
|
console.warn(`Could not read folder config at ${configPath}:`, e.message);
|
|
54
45
|
}
|
|
55
|
-
|
|
56
|
-
configCache.set(folderPath, null);
|
|
57
46
|
return null;
|
|
58
47
|
}
|
|
59
48
|
|