@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.
Files changed (44) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +72 -16
  3. package/bin/ursa.js +14 -1
  4. package/meta/templates/default-template/menu.js +18 -1
  5. package/meta/templates/default-template/search.js +11 -0
  6. package/meta/templates/default-template/widgets.js +4 -0
  7. package/package.json +1 -2
  8. package/src/dev.js +13 -23
  9. package/src/helper/__test__/contentHash.test.js +16 -6
  10. package/src/helper/assetBundler.js +93 -19
  11. package/src/helper/automenu.js +36 -11
  12. package/src/helper/build/__test__/autoIndex.test.js +2 -132
  13. package/src/helper/build/__test__/graph.test.js +259 -3
  14. package/src/helper/build/__test__/pass.test.js +553 -0
  15. package/src/helper/build/autoIndex.js +2 -371
  16. package/src/helper/build/excludeFilter.js +1 -2
  17. package/src/helper/build/footer.js +27 -14
  18. package/src/helper/build/graph.js +575 -152
  19. package/src/helper/build/index.js +0 -2
  20. package/src/helper/build/metadata.js +19 -5
  21. package/src/helper/build/pass.js +497 -0
  22. package/src/helper/build/precedence.js +174 -0
  23. package/src/helper/build/site.js +1270 -0
  24. package/src/helper/build/templates.js +1 -2
  25. package/src/helper/build/tracedFs.js +247 -0
  26. package/src/helper/contentHash.js +0 -78
  27. package/src/helper/customMenu.js +1 -1
  28. package/src/helper/fileRenderer.js +119 -111
  29. package/src/helper/findScriptJs.js +1 -1
  30. package/src/helper/findStyleCss.js +1 -1
  31. package/src/helper/folderConfig.js +7 -18
  32. package/src/helper/fullTextIndex.js +41 -29
  33. package/src/helper/imageProcessor.js +45 -0
  34. package/src/helper/linkValidator.js +118 -127
  35. package/src/helper/mdxRenderer.js +27 -5
  36. package/src/helper/menuLabels.js +30 -5
  37. package/src/helper/whitelistFilter.js +1 -2
  38. package/src/jobs/generate.js +67 -1829
  39. package/src/serve.js +317 -697
  40. package/src/helper/__test__/dependencyTracker.test.js +0 -157
  41. package/src/helper/build/cacheBust.js +0 -141
  42. package/src/helper/build/navCache.js +0 -145
  43. package/src/helper/build/watchCache.js +0 -33
  44. 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
- for (const doc of documents) {
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: doc.path, // path (shortened key for smaller JSON)
145
- s: count, // score (shortened key)
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 a set of valid internal paths from the list of source files and directories.
5
- * Returns a Map where keys are normalized paths (with/without extension) and values
6
- * are the canonical resolved paths (always with .html extension).
7
- * @param {string[]} sourceFiles - Array of source file paths
8
- * @param {string} source - Source directory path
9
- * @param {string[]} [directories] - Optional array of directory paths (for auto-index support)
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
- for (const file of sourceFiles) {
16
- // Get the path relative to source, without extension
17
- const ext = extname(file);
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
- relativePath = decodeURIComponent(relativePath);
32
+ rel = decodeURIComponent(rel);
28
33
  } catch (e) {
29
34
  // Ignore decode errors
30
35
  }
31
-
32
- // The canonical resolved path (always .html)
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
- // Add all directories as valid paths (they get auto-generated index.html)
47
+
48
+ // Folders
64
49
  for (const dir of directories) {
65
- let relativePath = dir.replace(source, "");
66
-
67
- // Normalize: ensure leading slash
68
- if (!relativePath.startsWith("/")) {
69
- relativePath = "/" + relativePath;
70
- }
71
-
72
- // Remove trailing slash for consistency
73
- if (relativePath.endsWith("/")) {
74
- relativePath = relativePath.slice(0, -1);
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
- // Check if path exists in validPaths map - the value is the canonical resolved path
208
- if (validPaths.has(normalized)) {
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
- // Check if the href already has an extension
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
- // Special handling for .md/.mdx links - always convert to .html
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
- // Check if .html version exists in validPaths for canonical path
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
- // Has extension but doesn't exist (or is not .md)
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.toLowerCase())) {
250
- const canonicalPath = validPaths.get(htmlPath.toLowerCase());
251
- debugTries.push(`${htmlPath} → ${canonicalPath} ✓`);
252
- return { resolvedHref: canonicalPath + hash, inactive: false, debug: debugTries.join(' | ') };
253
- }
254
- debugTries.push(`${htmlPath} → ✗`);
255
-
256
- // Try /index.html
257
- const indexPath = normalized.endsWith('/')
258
- ? normalized + 'index.html'
259
- : normalized + '/index.html';
260
- if (validPaths.has(indexPath.toLowerCase())) {
261
- const canonicalPath = validPaths.get(indexPath.toLowerCase());
262
- debugTries.push(`${indexPath} → ${canonicalPath} ✓`);
263
- return { resolvedHref: canonicalPath + hash, inactive: false, debug: debugTries.join(' | ') };
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
- debugTries.push(`${indexPath} → ✗`);
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>} validPaths - Map of normalized paths to canonical resolved paths
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 "fs";
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
- throw formatMDXError(error, filePath);
372
+ const formatted = formatMDXError(error, filePath);
373
+ formatted.inputs = [...inputs].sort();
374
+ formatted.componentDirs = componentDirs;
375
+ throw formatted;
354
376
  }
355
377
  }
356
378
 
@@ -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 "fs";
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
- if (!existsSync(filePath)) return null;
41
- const content = readFileSync(filePath, 'utf8');
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
  }
@@ -1,6 +1,5 @@
1
- import { readFile } from 'fs/promises';
1
+ import { readFile, existsSync } from './build/tracedFs.js';
2
2
  import { resolve, relative } from 'path';
3
- import { existsSync } from 'fs';
4
3
 
5
4
  /**
6
5
  * Creates a filter function based on a whitelist file