@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
|
@@ -113,49 +113,61 @@ function extractWords(text) {
|
|
|
113
113
|
* @returns {Object} - Inverted index: { word: [{ path, score }] }
|
|
114
114
|
*/
|
|
115
115
|
export function buildFullTextIndex(documents) {
|
|
116
|
+
return mergeWordCounts(
|
|
117
|
+
documents
|
|
118
|
+
.filter((doc) => doc.content || doc.title)
|
|
119
|
+
.map((doc) => ({ path: doc.path, counts: documentWordCounts(doc) }))
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Word → weighted count for one document: title words weigh 10, content
|
|
125
|
+
* words 1. This is the per-document half of the index, so the build graph can
|
|
126
|
+
* cache it per document and re-tokenize only what was edited.
|
|
127
|
+
* @param {{title: string, content: string}} doc
|
|
128
|
+
* @returns {Record<string, number>}
|
|
129
|
+
*/
|
|
130
|
+
export function documentWordCounts(doc) {
|
|
131
|
+
const wordCounts = {};
|
|
132
|
+
for (const word of extractWords(doc.title)) {
|
|
133
|
+
wordCounts[word] = (wordCounts[word] || 0) + 10;
|
|
134
|
+
}
|
|
135
|
+
for (const word of extractWords(doc.content)) {
|
|
136
|
+
wordCounts[word] = (wordCounts[word] || 0) + 1;
|
|
137
|
+
}
|
|
138
|
+
return wordCounts;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Merge per-document word counts into the inverted index.
|
|
143
|
+
* Deterministic: ties in score are broken by path, so the index is a pure
|
|
144
|
+
* function of its inputs regardless of the order documents arrive in.
|
|
145
|
+
* @param {Array<{path: string, counts: Record<string, number>}>} docs
|
|
146
|
+
* @returns {Object} - Inverted index: { word: [{ p: path, s: score }] }
|
|
147
|
+
*/
|
|
148
|
+
export function mergeWordCounts(docs) {
|
|
116
149
|
const index = {};
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
if (!doc.content && !doc.title) continue;
|
|
120
|
-
|
|
121
|
-
// Extract words from title (higher weight) and content
|
|
122
|
-
const titleWords = extractWords(doc.title);
|
|
123
|
-
const contentWords = extractWords(doc.content);
|
|
124
|
-
|
|
125
|
-
// Count word frequencies in this document
|
|
126
|
-
const wordCounts = {};
|
|
127
|
-
|
|
128
|
-
// Title words get weight of 10
|
|
129
|
-
for (const word of titleWords) {
|
|
130
|
-
wordCounts[word] = (wordCounts[word] || 0) + 10;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
// Content words get weight of 1
|
|
134
|
-
for (const word of contentWords) {
|
|
135
|
-
wordCounts[word] = (wordCounts[word] || 0) + 1;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
// Add to inverted index
|
|
139
|
-
for (const [word, count] of Object.entries(wordCounts)) {
|
|
150
|
+
for (const { path, counts } of docs) {
|
|
151
|
+
for (const [word, count] of Object.entries(counts)) {
|
|
140
152
|
if (!index[word]) {
|
|
141
153
|
index[word] = [];
|
|
142
154
|
}
|
|
143
155
|
index[word].push({
|
|
144
|
-
p:
|
|
145
|
-
s: count,
|
|
156
|
+
p: path, // path (shortened key for smaller JSON)
|
|
157
|
+
s: count, // score (shortened key)
|
|
146
158
|
});
|
|
147
159
|
}
|
|
148
160
|
}
|
|
149
|
-
|
|
150
|
-
// Sort each word's document list by score (descending)
|
|
161
|
+
|
|
162
|
+
// Sort each word's document list by score (descending), then path
|
|
151
163
|
for (const word of Object.keys(index)) {
|
|
152
|
-
index[word].sort((a, b) => b.s - a.s);
|
|
164
|
+
index[word].sort((a, b) => b.s - a.s || (a.p < b.p ? -1 : a.p > b.p ? 1 : 0));
|
|
153
165
|
// Limit to top 100 documents per word to keep index size reasonable
|
|
154
166
|
if (index[word].length > 100) {
|
|
155
167
|
index[word] = index[word].slice(0, 100);
|
|
156
168
|
}
|
|
157
169
|
}
|
|
158
|
-
|
|
170
|
+
|
|
159
171
|
return index;
|
|
160
172
|
}
|
|
161
173
|
|
|
@@ -122,6 +122,51 @@ async function isImageSmallEnough(sourcePath) {
|
|
|
122
122
|
}
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
+
/**
|
|
126
|
+
* Whether ursa handles this extension as an image at all.
|
|
127
|
+
*/
|
|
128
|
+
export function isImageExtension(ext) {
|
|
129
|
+
const e = ext.toLowerCase();
|
|
130
|
+
return PROCESSABLE_EXTENSIONS.includes(e) || COPY_ONLY_EXTENSIONS.includes(e);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Decide whether an image gets a WebP preview, without rendering one.
|
|
135
|
+
* SVG/ICO and unknown formats never do; neither does an image already within
|
|
136
|
+
* the preview bounds, nor anything when sharp is unavailable. Cheap: reads the
|
|
137
|
+
* header only. This is what a page needs to know to write its markup; the
|
|
138
|
+
* expensive encode (`renderPreview`) can then run after the page is served.
|
|
139
|
+
* @param {string} sourcePath - Absolute path to the image
|
|
140
|
+
* @returns {Promise<boolean>}
|
|
141
|
+
*/
|
|
142
|
+
export async function willHavePreview(sourcePath) {
|
|
143
|
+
const ext = extname(sourcePath).toLowerCase();
|
|
144
|
+
if (!PROCESSABLE_EXTENSIONS.includes(ext)) return false;
|
|
145
|
+
if (!(await ensureSharp())) return false;
|
|
146
|
+
return !(await isImageSmallEnough(sourcePath));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Encode the WebP preview of an image and return it.
|
|
151
|
+
* @param {string} sourcePath - Absolute path to the image
|
|
152
|
+
* @returns {Promise<Buffer|null>} The WebP bytes, or null when no preview can be made
|
|
153
|
+
*/
|
|
154
|
+
export async function renderPreview(sourcePath) {
|
|
155
|
+
if (!(await ensureSharp())) return null;
|
|
156
|
+
try {
|
|
157
|
+
return await sharp(sourcePath)
|
|
158
|
+
.resize(PREVIEW_MAX_WIDTH, PREVIEW_MAX_HEIGHT, {
|
|
159
|
+
fit: 'inside',
|
|
160
|
+
withoutEnlargement: true, // Don't upscale small images
|
|
161
|
+
})
|
|
162
|
+
.webp({ quality: PREVIEW_QUALITY })
|
|
163
|
+
.toBuffer();
|
|
164
|
+
} catch (e) {
|
|
165
|
+
console.warn(`⚠️ Failed to generate preview for ${basename(sourcePath)}: ${e.message}`);
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
125
170
|
/**
|
|
126
171
|
* Generate preview filename from original filename
|
|
127
172
|
* e.g., "photo.jpg" -> "photo.preview.webp"
|
|
@@ -1,97 +1,70 @@
|
|
|
1
1
|
import { extname, dirname, join, normalize, posix, basename } from "path";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Build
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
4
|
+
* Build the canonical-URL map used for link resolution: normalized (lowercased,
|
|
5
|
+
* extensionless or .html) paths → the `.html` output they name.
|
|
6
|
+
*
|
|
7
|
+
* Rules (docs/PATH_LOGIC.md, README "Link logic", docs/SERVE.md §8.3):
|
|
8
|
+
* - `/foo` names `foo.html` when a document `foo.*` exists — the file wins over
|
|
9
|
+
* a folder of the same name. With no such document it names the folder's
|
|
10
|
+
* index, `foo/index.html`.
|
|
11
|
+
* - `/foo/` and `/foo/index.html` name the folder's index page, which exists
|
|
12
|
+
* (as a document, a promoted alternate or the auto-index) for every folder
|
|
13
|
+
* that has documents somewhere beneath it.
|
|
14
|
+
*
|
|
15
|
+
* Which source owns an output is decided elsewhere (`outputOwner`); the map
|
|
16
|
+
* only says which output a URL means.
|
|
17
|
+
*
|
|
18
|
+
* @param {string[]} sourceFiles - Article source paths (absolute, or relative when `source` is "")
|
|
19
|
+
* @param {string} source - Source directory path (prefix stripped from every path)
|
|
20
|
+
* @param {string[]} [directories] - Directory paths in the same form
|
|
21
|
+
* @param {{dirsWithDocuments?: Set<string>}} [opts] - Relative dir paths (no leading slash)
|
|
22
|
+
* that hold documents; when omitted every directory is assumed to.
|
|
10
23
|
* @returns {Map<string, string>} Map of normalized paths to canonical resolved paths
|
|
11
24
|
*/
|
|
12
|
-
export function buildValidPaths(sourceFiles, source, directories = []) {
|
|
25
|
+
export function buildValidPaths(sourceFiles, source, directories = [], { dirsWithDocuments = null } = {}) {
|
|
13
26
|
const validPaths = new Map();
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
let relativePath = file.replace(source, "").replace(ext, "");
|
|
19
|
-
|
|
20
|
-
// Normalize: ensure leading slash, lowercase for comparison
|
|
21
|
-
if (!relativePath.startsWith("/")) {
|
|
22
|
-
relativePath = "/" + relativePath;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
// Decode URI components for paths with special characters (spaces, etc.)
|
|
27
|
+
|
|
28
|
+
const toRelative = (path) => {
|
|
29
|
+
let rel = source ? path.replace(source, "") : path;
|
|
30
|
+
if (!rel.startsWith("/")) rel = "/" + rel;
|
|
26
31
|
try {
|
|
27
|
-
|
|
32
|
+
rel = decodeURIComponent(rel);
|
|
28
33
|
} catch (e) {
|
|
29
34
|
// Ignore decode errors
|
|
30
35
|
}
|
|
31
|
-
|
|
32
|
-
|
|
36
|
+
return rel;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// Documents: the direct mapping is authoritative
|
|
40
|
+
for (const file of sourceFiles) {
|
|
41
|
+
const ext = extname(file);
|
|
42
|
+
const relativePath = toRelative(file.slice(0, file.length - ext.length));
|
|
33
43
|
const resolvedPath = relativePath + ".html";
|
|
34
|
-
|
|
35
|
-
// Add mappings: extensionless and with .html both resolve to the .html version
|
|
36
44
|
validPaths.set(relativePath.toLowerCase(), resolvedPath);
|
|
37
45
|
validPaths.set(resolvedPath.toLowerCase(), resolvedPath);
|
|
38
|
-
|
|
39
|
-
// Also add /index.html variant for directory indexes
|
|
40
|
-
if (relativePath.endsWith("/index")) {
|
|
41
|
-
const dirPath = relativePath.replace(/\/index$/, "");
|
|
42
|
-
const dirResolvedPath = dirPath + "/index.html";
|
|
43
|
-
validPaths.set(dirPath.toLowerCase(), dirResolvedPath);
|
|
44
|
-
validPaths.set((dirPath + "/").toLowerCase(), dirResolvedPath);
|
|
45
|
-
validPaths.set(dirResolvedPath.toLowerCase(), dirResolvedPath);
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
// Handle (foldername).md files - they get promoted to index.html by auto-index
|
|
49
|
-
// e.g., /foo/bar/bar.md becomes /foo/bar/index.html (bar.html promoted to index.html)
|
|
50
|
-
const fileName = basename(relativePath); // e.g., "bar" from "/foo/bar/bar"
|
|
51
|
-
const parentDir = dirname(relativePath); // e.g., "/foo/bar" from "/foo/bar/bar"
|
|
52
|
-
const parentDirName = basename(parentDir); // e.g., "bar" from "/foo/bar"
|
|
53
|
-
|
|
54
|
-
if (fileName === parentDirName) {
|
|
55
|
-
// This file has same name as its parent folder - it will be promoted to index.html
|
|
56
|
-
const promotedPath = parentDir + "/index.html";
|
|
57
|
-
validPaths.set(parentDir.toLowerCase(), promotedPath);
|
|
58
|
-
validPaths.set((parentDir + "/").toLowerCase(), promotedPath);
|
|
59
|
-
validPaths.set(promotedPath.toLowerCase(), promotedPath);
|
|
60
|
-
}
|
|
61
46
|
}
|
|
62
|
-
|
|
63
|
-
//
|
|
47
|
+
|
|
48
|
+
// Folders
|
|
64
49
|
for (const dir of directories) {
|
|
65
|
-
let relativePath = dir
|
|
66
|
-
|
|
67
|
-
//
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
// Decode URI components
|
|
78
|
-
try {
|
|
79
|
-
relativePath = decodeURIComponent(relativePath);
|
|
80
|
-
} catch (e) {
|
|
81
|
-
// Ignore decode errors
|
|
50
|
+
let relativePath = toRelative(dir);
|
|
51
|
+
if (relativePath.endsWith("/")) relativePath = relativePath.slice(0, -1);
|
|
52
|
+
if (relativePath === "") continue; // the root is handled below
|
|
53
|
+
const key = relativePath.toLowerCase();
|
|
54
|
+
const hasDocs = !dirsWithDocuments || dirsWithDocuments.has(relativePath.replace(/^\//, ""));
|
|
55
|
+
if (hasDocs) {
|
|
56
|
+
const indexPath = relativePath + "/index.html";
|
|
57
|
+
// `/foo` → the folder index, unless a document foo.* already claimed it
|
|
58
|
+
if (!validPaths.has(key)) validPaths.set(key, indexPath);
|
|
59
|
+
validPaths.set(key + "/", indexPath);
|
|
60
|
+
validPaths.set(indexPath.toLowerCase(), indexPath);
|
|
82
61
|
}
|
|
83
|
-
|
|
84
|
-
// All folders resolve to /folder/index.html
|
|
85
|
-
const resolvedPath = relativePath + "/index.html";
|
|
86
|
-
validPaths.set(relativePath.toLowerCase(), resolvedPath);
|
|
87
|
-
validPaths.set((relativePath + "/").toLowerCase(), resolvedPath);
|
|
88
|
-
validPaths.set(resolvedPath.toLowerCase(), resolvedPath);
|
|
89
62
|
}
|
|
90
|
-
|
|
63
|
+
|
|
91
64
|
// Add root
|
|
92
65
|
validPaths.set("/", "/index.html");
|
|
93
66
|
validPaths.set("/index.html", "/index.html");
|
|
94
|
-
|
|
67
|
+
|
|
95
68
|
return validPaths;
|
|
96
69
|
}
|
|
97
70
|
|
|
@@ -148,7 +121,7 @@ function resolveRelativePath(href, currentDocPath) {
|
|
|
148
121
|
* @param {string} currentDocPath - The current document's URL path (for relative link resolution)
|
|
149
122
|
* @returns {string} Normalized path
|
|
150
123
|
*/
|
|
151
|
-
function normalizeHref(href, currentDocPath = null) {
|
|
124
|
+
export function normalizeHref(href, currentDocPath = null) {
|
|
152
125
|
// Remove hash fragments
|
|
153
126
|
let normalized = href.split("#")[0];
|
|
154
127
|
|
|
@@ -189,6 +162,7 @@ function normalizeHref(href, currentDocPath = null) {
|
|
|
189
162
|
*/
|
|
190
163
|
function resolveHref(href, validPaths, currentDocPath = null) {
|
|
191
164
|
const debugTries = [];
|
|
165
|
+
const lookup = toLookup(validPaths);
|
|
192
166
|
|
|
193
167
|
// Get hash fragment if present (to preserve it)
|
|
194
168
|
const hashIndex = href.indexOf('#');
|
|
@@ -204,68 +178,84 @@ function resolveHref(href, validPaths, currentDocPath = null) {
|
|
|
204
178
|
? resolveRelativePath(hrefWithoutHash, currentDocPath)
|
|
205
179
|
: hrefWithoutHash;
|
|
206
180
|
|
|
207
|
-
|
|
208
|
-
if (
|
|
209
|
-
const canonicalPath = validPaths.get(normalized);
|
|
181
|
+
const canonicalPath = lookup(normalized);
|
|
182
|
+
if (canonicalPath) {
|
|
210
183
|
debugTries.push(`${normalized} → ${canonicalPath} ✓`);
|
|
211
184
|
return { resolvedHref: canonicalPath + hash, inactive: false, debug: debugTries.join(' | ') };
|
|
212
185
|
}
|
|
213
|
-
|
|
214
|
-
//
|
|
186
|
+
|
|
187
|
+
// A .md/.mdx link to a document that does not exist (yet) is still
|
|
188
|
+
// converted to .html optimistically: the target may be created later.
|
|
215
189
|
const ext = extname(hrefWithoutHash);
|
|
190
|
+
if (ext && (ext.toLowerCase() === '.md' || ext.toLowerCase() === '.mdx')) {
|
|
191
|
+
const resolvedHtmlPath = absoluteHref.replace(/\.(md|mdx)$/i, '.html');
|
|
192
|
+
debugTries.push(`${normalized} (${ext} → .html optimistic) → ${resolvedHtmlPath}`);
|
|
193
|
+
return { resolvedHref: resolvedHtmlPath + hash, inactive: false, debug: debugTries.join(' | ') };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Nothing owns it - mark as inactive, keep absolute href
|
|
197
|
+
debugTries.push(`${normalized} → ✗`);
|
|
198
|
+
return { resolvedHref: absoluteHref + hash, inactive: true, debug: debugTries.join(' | ') };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Resolve one normalized href (lowercased, absolute, no hash or query — see
|
|
203
|
+
* `normalizeHref`) to the canonical `.html` output it names, or null.
|
|
204
|
+
*
|
|
205
|
+
* This is the whole of link resolution's dependence on the site's path set,
|
|
206
|
+
* isolated so the build graph can hold one `linkResolution` node per distinct
|
|
207
|
+
* href: adding a document then rewrites only the pages whose links resolve
|
|
208
|
+
* differently, not every page.
|
|
209
|
+
*
|
|
210
|
+
* @param {string} normalized
|
|
211
|
+
* @param {Map<string, string>} validPaths
|
|
212
|
+
* @returns {string|null}
|
|
213
|
+
*/
|
|
214
|
+
export function resolveNormalizedHref(normalized, validPaths) {
|
|
215
|
+
if (validPaths.has(normalized)) return validPaths.get(normalized);
|
|
216
|
+
|
|
217
|
+
const ext = extname(normalized);
|
|
216
218
|
if (ext) {
|
|
217
|
-
|
|
218
|
-
// This ensures links work even if the target file is created after serve starts
|
|
219
|
-
if (ext.toLowerCase() === '.md' || ext.toLowerCase() === '.mdx') {
|
|
220
|
-
// Remove source extension and convert to .html
|
|
219
|
+
if (ext === '.md' || ext === '.mdx') {
|
|
221
220
|
const pathWithoutExt = normalized.slice(0, -ext.length);
|
|
222
221
|
const htmlPath = pathWithoutExt + '.html';
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
if (validPaths.has(htmlPath.toLowerCase())) {
|
|
226
|
-
const canonicalPath = validPaths.get(htmlPath.toLowerCase());
|
|
227
|
-
debugTries.push(`${normalized} (${ext} → .html) → ${canonicalPath} ✓`);
|
|
228
|
-
return { resolvedHref: canonicalPath + hash, inactive: false, debug: debugTries.join(' | ') };
|
|
229
|
-
}
|
|
230
|
-
// Also check without extension
|
|
231
|
-
if (validPaths.has(pathWithoutExt.toLowerCase())) {
|
|
232
|
-
const canonicalPath = validPaths.get(pathWithoutExt.toLowerCase());
|
|
233
|
-
debugTries.push(`${normalized} (${ext} → resolved) → ${canonicalPath} ✓`);
|
|
234
|
-
return { resolvedHref: canonicalPath + hash, inactive: false, debug: debugTries.join(' | ') };
|
|
235
|
-
}
|
|
236
|
-
// File doesn't exist yet, but still convert to .html optimistically
|
|
237
|
-
// (the target file may be created later during serve)
|
|
238
|
-
const resolvedHtmlPath = absoluteHref.replace(/\.(md|mdx)$/i, '.html');
|
|
239
|
-
debugTries.push(`${normalized} (${ext} → .html optimistic) → ${resolvedHtmlPath}`);
|
|
240
|
-
return { resolvedHref: resolvedHtmlPath + hash, inactive: false, debug: debugTries.join(' | ') };
|
|
222
|
+
if (validPaths.has(htmlPath)) return validPaths.get(htmlPath);
|
|
223
|
+
if (validPaths.has(pathWithoutExt)) return validPaths.get(pathWithoutExt);
|
|
241
224
|
}
|
|
242
|
-
|
|
243
|
-
debugTries.push(`${normalized} → ✗`);
|
|
244
|
-
return { resolvedHref: absoluteHref + hash, inactive: true, debug: debugTries.join(' | ') };
|
|
225
|
+
return null;
|
|
245
226
|
}
|
|
246
|
-
|
|
247
|
-
// No extension - try .html first
|
|
227
|
+
|
|
228
|
+
// No extension - try .html first, then /index.html
|
|
248
229
|
const htmlPath = normalized + '.html';
|
|
249
|
-
if (validPaths.has(htmlPath.
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
230
|
+
if (validPaths.has(htmlPath)) return validPaths.get(htmlPath);
|
|
231
|
+
const indexPath = normalized.endsWith('/') ? normalized + 'index.html' : normalized + '/index.html';
|
|
232
|
+
if (validPaths.has(indexPath)) return validPaths.get(indexPath);
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Accept either a validPaths Map or a `(normalized) => canonical|null` function. */
|
|
237
|
+
function toLookup(validPathsOrResolver) {
|
|
238
|
+
if (typeof validPathsOrResolver === 'function') return validPathsOrResolver;
|
|
239
|
+
return (normalized) => resolveNormalizedHref(normalized, validPathsOrResolver);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Every internal link target in the HTML, normalized the way resolution sees
|
|
244
|
+
* it. Lets a page ask the build graph for each target before rewriting.
|
|
245
|
+
* @param {string} html
|
|
246
|
+
* @param {string} currentDocPath - The current document's URL path
|
|
247
|
+
* @returns {string[]} Distinct normalized hrefs
|
|
248
|
+
*/
|
|
249
|
+
export function collectInternalHrefs(html, currentDocPath = '/') {
|
|
250
|
+
const out = new Set();
|
|
251
|
+
const re = /<a\s+[^>]*?href=["']([^"']+)["'][^>]*>/gi;
|
|
252
|
+
let m;
|
|
253
|
+
while ((m = re.exec(html)) !== null) {
|
|
254
|
+
const href = m[1];
|
|
255
|
+
if (!isInternalLink(href)) continue;
|
|
256
|
+
out.add(normalizeHref(href.split('#')[0], currentDocPath));
|
|
264
257
|
}
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
// Neither exists - mark as inactive, keep absolute href
|
|
268
|
-
return { resolvedHref: absoluteHref + hash, inactive: true, debug: debugTries.join(' | ') };
|
|
258
|
+
return [...out];
|
|
269
259
|
}
|
|
270
260
|
|
|
271
261
|
/**
|
|
@@ -276,7 +266,8 @@ function resolveHref(href, validPaths, currentDocPath = null) {
|
|
|
276
266
|
* 3. Marks broken links with the "inactive" class
|
|
277
267
|
*
|
|
278
268
|
* @param {string} html - The HTML content
|
|
279
|
-
* @param {Map<string, string
|
|
269
|
+
* @param {Map<string, string>|((normalized: string) => string|null)} validPaths - Map of
|
|
270
|
+
* normalized paths to canonical resolved paths, or a resolver over normalized hrefs
|
|
280
271
|
* @param {string} currentDocPath - The current document's URL path (e.g., "/character/index.html")
|
|
281
272
|
* @param {boolean} includeDebug - Whether to include debug info in link text
|
|
282
273
|
* @returns {string} Processed HTML with resolved links and inactive class on broken links
|
|
@@ -4,7 +4,7 @@ import React from "react";
|
|
|
4
4
|
import { renderToString } from "react-dom/server";
|
|
5
5
|
import * as esbuild from "esbuild";
|
|
6
6
|
import { dirname, extname, join, resolve, sep } from "path";
|
|
7
|
-
import { existsSync } from "
|
|
7
|
+
import { existsSync } from "./build/tracedFs.js";
|
|
8
8
|
import { readFile, writeFile, mkdir } from "fs/promises";
|
|
9
9
|
import remarkDirective from "remark-directive";
|
|
10
10
|
import { remarkDefinitionList, defListHastHandlers } from "remark-definition-list";
|
|
@@ -245,11 +245,29 @@ function findComponentDirs(startDir, sourceRoot) {
|
|
|
245
245
|
* @param {string} options.filePath - Absolute path to the MDX file (used for import resolution)
|
|
246
246
|
* @param {string} [options.sourceRoot] - Root directory of the source files (for absolute imports)
|
|
247
247
|
* @param {boolean} [options.hydrate=false] - If true, includes client bundle for hydration
|
|
248
|
-
* @returns {Promise<{ html: string, frontmatter: Record<string, any>, clientCode?: string }>}
|
|
248
|
+
* @returns {Promise<{ html: string, frontmatter: Record<string, any>, clientCode?: string, inputs: string[] }>}
|
|
249
|
+
* `inputs` is every file esbuild loaded while bundling (components, their
|
|
250
|
+
* imports, anything under `_components`), excluding node_modules — the
|
|
251
|
+
* document's real dependency set, so the build can re-render exactly the
|
|
252
|
+
* pages that import an edited component.
|
|
249
253
|
*/
|
|
250
254
|
export async function renderMDX({ source, filePath, sourceRoot, hydrate = false }) {
|
|
251
255
|
const cwd = dirname(filePath);
|
|
252
256
|
const componentDirs = findComponentDirs(cwd, sourceRoot);
|
|
257
|
+
const inputs = new Set();
|
|
258
|
+
|
|
259
|
+
/** Records every file esbuild loads; returns nothing so the real loaders still run. */
|
|
260
|
+
const inputRecorderPlugin = {
|
|
261
|
+
name: "ursa-input-recorder",
|
|
262
|
+
setup(build) {
|
|
263
|
+
build.onLoad({ filter: /.*/ }, (args) => {
|
|
264
|
+
if (args.namespace === "file" && !args.path.includes(`${sep}node_modules${sep}`)) {
|
|
265
|
+
inputs.add(args.path);
|
|
266
|
+
}
|
|
267
|
+
return undefined;
|
|
268
|
+
});
|
|
269
|
+
},
|
|
270
|
+
};
|
|
253
271
|
|
|
254
272
|
/**
|
|
255
273
|
* Create esbuild options for the given platform
|
|
@@ -277,7 +295,7 @@ export async function renderMDX({ source, filePath, sourceRoot, hydrate = false
|
|
|
277
295
|
}
|
|
278
296
|
|
|
279
297
|
// Island plugin goes first so it sees component imports before mdx-bundler's resolvers
|
|
280
|
-
options.plugins = [islandPlugin(platform), ...(options.plugins || [])];
|
|
298
|
+
options.plugins = [inputRecorderPlugin, islandPlugin(platform), ...(options.plugins || [])];
|
|
281
299
|
|
|
282
300
|
return options;
|
|
283
301
|
};
|
|
@@ -331,7 +349,7 @@ export async function renderMDX({ source, filePath, sourceRoot, hydrate = false
|
|
|
331
349
|
|
|
332
350
|
// If hydration is not requested, return without client code
|
|
333
351
|
if (!hydrate) {
|
|
334
|
-
return { html, frontmatter: frontmatter || {} };
|
|
352
|
+
return { html, frontmatter: frontmatter || {}, inputs: [...inputs].sort() };
|
|
335
353
|
}
|
|
336
354
|
|
|
337
355
|
// Client-side bundle (for hydration)
|
|
@@ -348,9 +366,13 @@ export async function renderMDX({ source, filePath, sourceRoot, hydrate = false
|
|
|
348
366
|
html,
|
|
349
367
|
frontmatter: frontmatter || {},
|
|
350
368
|
clientCode: clientResult.code,
|
|
369
|
+
inputs: [...inputs].sort(),
|
|
351
370
|
};
|
|
352
371
|
} catch (error) {
|
|
353
|
-
|
|
372
|
+
const formatted = formatMDXError(error, filePath);
|
|
373
|
+
formatted.inputs = [...inputs].sort();
|
|
374
|
+
formatted.componentDirs = componentDirs;
|
|
375
|
+
throw formatted;
|
|
354
376
|
}
|
|
355
377
|
}
|
|
356
378
|
|
package/src/helper/menuLabels.js
CHANGED
|
@@ -7,9 +7,9 @@
|
|
|
7
7
|
* resolution rules here is what makes `menu-label: 'BNW - Brave New World'`
|
|
8
8
|
* show up in both places instead of only in the sidebar.
|
|
9
9
|
*/
|
|
10
|
-
import { existsSync, readFileSync } from "
|
|
10
|
+
import { existsSync, readFileSync, currentRecorder } from "./build/tracedFs.js";
|
|
11
11
|
import { basename, extname, join } from "path";
|
|
12
|
-
import { extractMetadata } from "./metadataExtractor.js";
|
|
12
|
+
import { extractMetadata, isMetadataOnly } from "./metadataExtractor.js";
|
|
13
13
|
import { stripHtml } from "./stripHtml.js";
|
|
14
14
|
|
|
15
15
|
// Index file extensions to check for folder metadata
|
|
@@ -29,6 +29,32 @@ export function toDisplayName(filename) {
|
|
|
29
29
|
.replace(/\b\w/g, c => c.toUpperCase()); // Capitalize first letter of each word
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* A document's frontmatter and whether it is metadata-only, or null when the
|
|
34
|
+
* file does not exist.
|
|
35
|
+
*
|
|
36
|
+
* When a build-graph node is computing, it may have pre-loaded this from the
|
|
37
|
+
* document's `docMeta` node into the active recorder's `frontmatter` map. A
|
|
38
|
+
* hit there records nothing further — the edge to `docMeta` already exists,
|
|
39
|
+
* and it is a projection that a body edit leaves unchanged. That is what
|
|
40
|
+
* keeps a paragraph edit from reaching the menu, the auto-indices and every
|
|
41
|
+
* breadcrumb beneath the folder. A miss reads the file (and records it).
|
|
42
|
+
*
|
|
43
|
+
* @param {string} filePath - Path to the source file
|
|
44
|
+
* @returns {{meta: object|null, isMetadataOnly: boolean}|null}
|
|
45
|
+
*/
|
|
46
|
+
export function readFrontmatterInfo(filePath) {
|
|
47
|
+
const cached = currentRecorder()?.frontmatter?.get(filePath);
|
|
48
|
+
if (cached !== undefined) return cached;
|
|
49
|
+
try {
|
|
50
|
+
if (!existsSync(filePath)) return null;
|
|
51
|
+
const content = readFileSync(filePath, 'utf8');
|
|
52
|
+
return { meta: extractMetadata(content), isMetadataOnly: isMetadataOnly(content) };
|
|
53
|
+
} catch (e) {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
32
58
|
/**
|
|
33
59
|
* Read a single frontmatter key from a file, with HTML stripped.
|
|
34
60
|
* @param {string} filePath - Path to the source file
|
|
@@ -37,9 +63,8 @@ export function toDisplayName(filename) {
|
|
|
37
63
|
*/
|
|
38
64
|
function getFrontmatterString(filePath, key) {
|
|
39
65
|
try {
|
|
40
|
-
|
|
41
|
-
const
|
|
42
|
-
const metadata = extractMetadata(content);
|
|
66
|
+
const info = readFrontmatterInfo(filePath);
|
|
67
|
+
const metadata = info?.meta;
|
|
43
68
|
if (metadata && metadata[key]) {
|
|
44
69
|
return stripHtml(String(metadata[key]));
|
|
45
70
|
}
|