@kenjura/ursa 0.95.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 (52) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/README.md +114 -16
  3. package/bin/ursa.js +14 -1
  4. package/meta/templates/default-template/content-hooks.js +45 -0
  5. package/meta/templates/default-template/index.html +1 -0
  6. package/meta/templates/default-template/menu.js +18 -1
  7. package/meta/templates/default-template/search.js +11 -0
  8. package/meta/templates/default-template/sticky.js +7 -1
  9. package/meta/templates/default-template/toc-generator.js +58 -38
  10. package/meta/templates/default-template/widgets.js +4 -0
  11. package/package.json +1 -2
  12. package/src/dev.js +13 -23
  13. package/src/helper/__test__/contentHash.test.js +16 -6
  14. package/src/helper/__test__/mdxRenderer.test.js +159 -0
  15. package/src/helper/__test__/sourceTimestamps.test.js +0 -0
  16. package/src/helper/assetBundler.js +93 -19
  17. package/src/helper/automenu.js +36 -11
  18. package/src/helper/build/__test__/autoIndex.test.js +2 -132
  19. package/src/helper/build/__test__/graph.test.js +259 -3
  20. package/src/helper/build/__test__/pass.test.js +553 -0
  21. package/src/helper/build/autoIndex.js +2 -371
  22. package/src/helper/build/excludeFilter.js +1 -2
  23. package/src/helper/build/footer.js +27 -14
  24. package/src/helper/build/graph.js +575 -152
  25. package/src/helper/build/index.js +0 -2
  26. package/src/helper/build/metadata.js +19 -5
  27. package/src/helper/build/pass.js +497 -0
  28. package/src/helper/build/precedence.js +174 -0
  29. package/src/helper/build/site.js +1270 -0
  30. package/src/helper/build/templates.js +1 -2
  31. package/src/helper/build/tracedFs.js +247 -0
  32. package/src/helper/contentHash.js +0 -78
  33. package/src/helper/customMenu.js +1 -1
  34. package/src/helper/fileRenderer.js +119 -111
  35. package/src/helper/findScriptJs.js +1 -1
  36. package/src/helper/findStyleCss.js +1 -1
  37. package/src/helper/folderConfig.js +7 -18
  38. package/src/helper/fullTextIndex.js +41 -29
  39. package/src/helper/imageProcessor.js +45 -0
  40. package/src/helper/linkValidator.js +118 -127
  41. package/src/helper/mdxRenderer.js +225 -26
  42. package/src/helper/menuLabels.js +30 -5
  43. package/src/helper/sourceTimestamps.js +139 -0
  44. package/src/helper/ursaConfig.js +3 -49
  45. package/src/helper/whitelistFilter.js +1 -2
  46. package/src/jobs/generate.js +67 -1859
  47. package/src/serve.js +317 -697
  48. package/src/helper/__test__/dependencyTracker.test.js +0 -157
  49. package/src/helper/build/cacheBust.js +0 -141
  50. package/src/helper/build/navCache.js +0 -145
  51. package/src/helper/build/watchCache.js +0 -33
  52. package/src/helper/dependencyTracker.js +0 -384
@@ -0,0 +1,1270 @@
1
+ /**
2
+ * The build as a graph of nodes (docs/SERVE.md §4).
3
+ *
4
+ * Every output file the build produces is owned by exactly one node defined
5
+ * here, and every node's compute function reads its inputs through the graph
6
+ * — `ctx.read`, `ctx.exists`, `ctx.listDir`, `ctx.get` — or through helpers
7
+ * whose fs calls are traced (tracedFs.js), so the engine records exactly what
8
+ * each output consumed. Nothing in this file decides what to rebuild; it only
9
+ * says how each thing is built and what it reads. The engine does the rest.
10
+ *
11
+ * `generate` and `serve` run the same nodes (pass.js). Node ids are
12
+ * `kind:key`, where the key is a path relative to the docroot (documents,
13
+ * directories, images), a template name (meta), or empty (site-wide).
14
+ */
15
+
16
+ import { basename, dirname, extname, join, posix, relative } from "path";
17
+ import { mkdir, readFile as fsReadFile, writeFile } from "fs/promises";
18
+ import { existsSync } from "fs";
19
+ import * as esbuild from "esbuild";
20
+ import o2x from "object-to-xml";
21
+
22
+ import { hashBytes, currentRecorder } from "./tracedFs.js";
23
+ import {
24
+ ARTICLE_EXTENSIONS,
25
+ AUTO_INDEX,
26
+ INDEX_BASENAMES,
27
+ candidatesForOutput,
28
+ isArticle,
29
+ isHandwrittenHtml,
30
+ isIndexBasename,
31
+ isIndexCandidate,
32
+ outputPathFor,
33
+ } from "./precedence.js";
34
+ import { getTemplates } from "./templates.js";
35
+ import { getFooter } from "./footer.js";
36
+ import { getTransformedMetadata } from "./metadata.js";
37
+ import { toTitleCase } from "./titleCase.js";
38
+ import { parseExcludeOption, createExcludeFilter } from "./excludeFilter.js";
39
+ import { generateAutoIndexHtmlFromSource } from "./autoIndex.js";
40
+ import { getUrsaVersion } from "../ursaVersion.js";
41
+ import { createWhitelistFilter } from "../whitelistFilter.js";
42
+ import { isHiddenOrSystemPath } from "../hiddenPaths.js";
43
+ import { getFolderConfig, isFolderHidden, isFolderSelfHidden } from "../folderConfig.js";
44
+ import { getFolderLabel } from "../menuLabels.js";
45
+ import { IMAGE_EXTENSIONS, isMedia } from "../staticAssets.js";
46
+ import { extractMetadata, isMetadataOnly, getAutoIndexConfig } from "../metadataExtractor.js";
47
+ import { injectFrontmatterTable } from "../frontmatterTable.js";
48
+ import { extractSections } from "../sectionExtractor.js";
49
+ import { renderFile, renderFileAsync } from "../fileRenderer.js";
50
+ import { generateBreadcrumbs } from "../breadcrumbs.js";
51
+ import { getAutomenu } from "../automenu.js";
52
+ import {
53
+ findCustomMenu,
54
+ extractMenuFrontmatter,
55
+ parseCustomMenu,
56
+ combineAutoAndManualMenu,
57
+ } from "../customMenu.js";
58
+ import { findAllStyleCss } from "../findStyleCss.js";
59
+ import { findAllScriptJs } from "../findScriptJs.js";
60
+ import {
61
+ parseTemplateAssets,
62
+ rewriteTemplateWithBundles,
63
+ bundleCssContent,
64
+ bundleJsContent,
65
+ resolveMetaAssetPath,
66
+ versionCssUrls,
67
+ rewriteJsonFetches,
68
+ } from "../assetBundler.js";
69
+ import {
70
+ buildValidPaths,
71
+ collectInternalHrefs,
72
+ markInactiveLinks,
73
+ resolveNormalizedHref,
74
+ resolveRelativeUrls,
75
+ } from "../linkValidator.js";
76
+ import { transformImageTags, willHavePreview, renderPreview, getPreviewFilename, isImageExtension } from "../imageProcessor.js";
77
+ import { documentWordCounts, mergeWordCounts } from "../fullTextIndex.js";
78
+ import { buildSourceTimestampIndex } from "../sourceTimestamps.js";
79
+
80
+ const DEFAULT_TEMPLATE_NAME = process.env.DEFAULT_TEMPLATE_NAME ?? "default-template";
81
+ const MENU_FILE_NAMES = ["menu.md", "menu.txt", "_menu.md", "_menu.txt"];
82
+ const REACT_RUNTIME_MARKER = "ursa-react-runtime/2";
83
+
84
+ export function nodeId(kind, key = "") {
85
+ return `${kind}:${key}`;
86
+ }
87
+
88
+ export function parseNodeId(id) {
89
+ const i = id.indexOf(":");
90
+ return { kind: id.slice(0, i), key: id.slice(i + 1) };
91
+ }
92
+
93
+ /** Node kinds whose key is a document path in the document set. */
94
+ export const DOC_KINDS = ["bodyHtml", "pageHtml", "docData", "docWords"];
95
+ /** Node kinds whose key is a directory in the document set ("" is the root). */
96
+ export const DIR_KINDS = ["dirIndexJson", "dirListingHtml", "autoIndexPage", "dirSet"];
97
+ /** Node kinds kept only while something depends on them. */
98
+ export const INTERNAL_KINDS = [
99
+ "linkResolution", "outputOwner", "customMenuFor", "cssBundle", "jsBundle",
100
+ "metaAsset", "metaBundle", "imageInfo", "docMeta",
101
+ ];
102
+ /** Site-wide singletons. */
103
+ export const SITE_KINDS = [
104
+ "documentSet", "validPaths", "templates", "metaAssets", "menuData", "menuHtml",
105
+ "footer", "reactRuntime", "ursaMetadata", "searchIndex", "fullTextIndex",
106
+ "recentActivity", "customMenus",
107
+ ];
108
+
109
+ /**
110
+ * Create the node definitions for one site.
111
+ *
112
+ * @param {object} env
113
+ * @param {string} env.source - Absolute docroot (no trailing slash)
114
+ * @param {string} env.meta - Absolute meta directory
115
+ * @param {string} env.output - Absolute output directory
116
+ * @param {string|null} env.whitelist - Whitelist file path
117
+ * @param {string|null} env.exclude - Exclude option (paths or a file)
118
+ * @param {boolean} env.jsonOnly - Emit only .json data files
119
+ * @param {{buildId: number, now: Date, gitHash: string|null}} env.session - Per-session build metadata (§7)
120
+ * @param {(msg: string) => void} env.log
121
+ * @param {(key: string, msg: string) => void} env.warn - De-duplicated per pass
122
+ * @returns {{resolve: (id: string) => ({fn: Function, fingerprint?: Function}|null)}}
123
+ */
124
+ export function createSite(env) {
125
+ const { source, meta, output } = env;
126
+ const log = env.log ?? (() => {});
127
+ const warn = env.warn ?? ((k, m) => console.warn(m));
128
+ const abs = (rel) => (rel ? join(source, rel) : source);
129
+ const outAbs = (rel) => join(output, rel);
130
+ const relOf = (absPath) => relative(source, absPath).split("\\").join("/");
131
+ const urlOf = (rel) => "/" + rel;
132
+
133
+ // -------------------------------------------------------------------------
134
+ // Output writing
135
+ // -------------------------------------------------------------------------
136
+
137
+ /**
138
+ * Write an output file only when its bytes differ (minimality, §1) and
139
+ * declare ownership of it. Returns the content hash.
140
+ */
141
+ async function writeOutput(ctx, rel, content) {
142
+ const path = outAbs(rel);
143
+ const buf = Buffer.isBuffer(content) ? content : Buffer.from(content, "utf8");
144
+ const hash = hashBytes(buf);
145
+ let same = false;
146
+ try {
147
+ const existing = await fsReadFile(path);
148
+ same = existing.length === buf.length && hashBytes(existing) === hash;
149
+ } catch {
150
+ same = false;
151
+ }
152
+ if (!same) {
153
+ await mkdir(dirname(path), { recursive: true });
154
+ await writeFile(path, buf);
155
+ env.onWrite?.(rel);
156
+ }
157
+ ctx.own(rel);
158
+ return hash;
159
+ }
160
+
161
+ // -------------------------------------------------------------------------
162
+ // Shared helpers
163
+ // -------------------------------------------------------------------------
164
+
165
+ /** Filename-derived title, as the search index and recent activity name a document. */
166
+ function titleOf(rel) {
167
+ const ext = extname(rel);
168
+ const base = basename(rel, ext);
169
+ const dir = dirname(rel);
170
+ const titleBase = base === "index" || base === "home" ? basename(dir === "." ? source : dir) : base;
171
+ return toTitleCase(titleBase || base);
172
+ }
173
+
174
+ /** Directory of a document relative to the docroot, with trailing slash ("" at the root). */
175
+ function dirWithSlash(rel) {
176
+ const d = dirname(rel);
177
+ return d === "." ? "" : d + "/";
178
+ }
179
+
180
+ function dirRelOf(rel) {
181
+ const d = dirname(rel);
182
+ return d === "." ? "" : d;
183
+ }
184
+
185
+ function indexOutputFor(dirRel) {
186
+ return dirRel ? `${dirRel}/index.html` : "index.html";
187
+ }
188
+
189
+ /**
190
+ * Pre-load frontmatter for the given documents into the active recorder so
191
+ * label lookups made by the menu/breadcrumb/auto-index helpers hit `docMeta`
192
+ * projections instead of file contents (see menuLabels.readFrontmatterInfo).
193
+ */
194
+ async function preloadFrontmatter(ctx, docs, { nonDocuments = [] } = {}) {
195
+ const rec = currentRecorder();
196
+ if (!rec) return;
197
+ if (!rec.frontmatter) rec.frontmatter = new Map();
198
+ for (const d of docs) {
199
+ if (!isArticle(d)) continue;
200
+ const info = await ctx.get(nodeId("docMeta", d));
201
+ rec.frontmatter.set(abs(d), { meta: info.meta, isMetadataOnly: info.isMetadataOnly });
202
+ }
203
+ // Files that are not documents have no frontmatter worth reading; a
204
+ // stylesheet's or image's content must not become an input of the menu
205
+ const none = { meta: null, isMetadataOnly: false };
206
+ for (const f of nonDocuments) rec.frontmatter.set(abs(f), none);
207
+ }
208
+
209
+ /**
210
+ * Documents in a folder (and, one level down, the index documents of its
211
+ * subfolders) whose labels an auto-index listing shows. Read from directory
212
+ * listings only, so a document added elsewhere is not an input.
213
+ */
214
+ async function indexDocsAround(ctx, dirRel) {
215
+ const out = [];
216
+ for (const entry of await ctx.listDir(abs(dirRel))) {
217
+ const rel = dirRel ? `${dirRel}/${entry.name}` : entry.name;
218
+ if (entry.kind === "file" && isArticle(entry.name)) out.push(rel);
219
+ if (entry.kind === "dir") {
220
+ for (const child of await ctx.listDir(abs(rel))) {
221
+ if (child.kind === "file" && isArticle(child.name) && isIndexBasename(basename(child.name, extname(child.name)))) {
222
+ out.push(`${rel}/${child.name}`);
223
+ }
224
+ }
225
+ }
226
+ }
227
+ return out;
228
+ }
229
+
230
+ /** Every document at or below a folder, to `depth` levels (Infinity for all). */
231
+ async function docsBelow(ctx, dirRel, depth) {
232
+ const out = [];
233
+ const walk = async (rel, level) => {
234
+ for (const entry of await ctx.listDir(abs(rel))) {
235
+ const childRel = rel ? `${rel}/${entry.name}` : entry.name;
236
+ if (entry.kind === "file" && isArticle(entry.name)) out.push(childRel);
237
+ else if (entry.kind === "dir" && level < depth && !isHiddenOrSystemPath(abs(childRel), source)) await walk(childRel, level + 1);
238
+ }
239
+ };
240
+ await walk(dirRel, 1);
241
+ return out;
242
+ }
243
+
244
+ /**
245
+ * Ancestors' index documents, for breadcrumb labels: each candidate name is
246
+ * probed (a recorded lookup), so only the folder's own index files are inputs.
247
+ */
248
+ function ancestorIndexDocs(ctx, dirRel) {
249
+ const out = [];
250
+ let cur = dirRel;
251
+ while (cur) {
252
+ for (const base of INDEX_BASENAMES) {
253
+ for (const ext of ARTICLE_EXTENSIONS) {
254
+ const candidate = `${cur}/${base}${ext}`;
255
+ if (ctx.exists(abs(candidate))) out.push(candidate);
256
+ }
257
+ }
258
+ const parent = dirname(cur);
259
+ cur = parent === "." ? "" : parent;
260
+ }
261
+ return out;
262
+ }
263
+
264
+ /**
265
+ * Resolve the internal links in HTML through per-href nodes and mark the
266
+ * broken ones inactive.
267
+ */
268
+ async function resolveLinks(ctx, html, docUrlPath) {
269
+ const hrefs = collectInternalHrefs(html, docUrlPath);
270
+ const map = new Map();
271
+ for (const h of hrefs) map.set(h, await ctx.get(nodeId("linkResolution", h)));
272
+ return markInactiveLinks(html, (n) => map.get(n) ?? null, docUrlPath);
273
+ }
274
+
275
+ /** Site-absolute lookup path for an image src, the way transformImageTags resolves it. */
276
+ function imageLookupPath(src, docUrlPath) {
277
+ let lookupPath = src.split("?")[0].split("#")[0];
278
+ if (!lookupPath.startsWith("/")) {
279
+ const docDir = docUrlPath.substring(0, docUrlPath.lastIndexOf("/")) || "/";
280
+ const parts = docDir.split("/").filter(Boolean);
281
+ for (const part of lookupPath.split("/")) {
282
+ if (part === "..") parts.pop();
283
+ else if (part !== ".") parts.push(part);
284
+ }
285
+ lookupPath = "/" + parts.join("/");
286
+ }
287
+ try {
288
+ lookupPath = decodeURIComponent(lookupPath);
289
+ } catch {
290
+ // keep as-is
291
+ }
292
+ return lookupPath;
293
+ }
294
+
295
+ /**
296
+ * Rewrite <img> tags to previews with lightbox anchors, and version every
297
+ * image, stylesheet and script reference with the content hash of what it
298
+ * points at. Returns the HTML and the images (docroot-relative) it uses.
299
+ */
300
+ async function finishAssets(ctx, html, docUrlPath) {
301
+ // Images referenced by the page: one imageInfo node each
302
+ const imgRe = /<img[^>]*src=["']([^"']+)["'][^>]*>/gi;
303
+ const imageMap = new Map();
304
+ const hashes = new Map(); // site-absolute URL (no query) → hash
305
+ const images = [];
306
+ let m;
307
+ while ((m = imgRe.exec(html)) !== null) {
308
+ const src = m[1];
309
+ if (/^(https?:)?\/\/|^data:/i.test(src)) continue;
310
+ const lookup = imageLookupPath(src, docUrlPath);
311
+ if (imageMap.has(lookup)) continue;
312
+ const rel = lookup.replace(/^\//, "");
313
+ if (!IMAGE_EXTENSIONS.test(rel)) continue;
314
+ const info = await ctx.get(nodeId("imageInfo", rel));
315
+ if (info) {
316
+ imageMap.set(lookup, { original: info.original, preview: info.preview });
317
+ hashes.set(info.original, info.hash);
318
+ hashes.set(info.preview, info.hash);
319
+ images.push(rel);
320
+ } else {
321
+ imageMap.set(lookup, null);
322
+ }
323
+ }
324
+ for (const [k, v] of [...imageMap]) if (!v) imageMap.delete(k);
325
+ html = transformImageTags(html, imageMap, docUrlPath);
326
+
327
+ // Stylesheets and scripts: the react runtime, meta assets, docroot assets
328
+ const refRe = /<(?:link[^>]+href|script[^>]+src)=["']([^"'?]+\.(?:css|js))["']/gi;
329
+ while ((m = refRe.exec(html)) !== null) {
330
+ const url = m[1];
331
+ if (hashes.has(url) || /^(https?:)?\/\//i.test(url)) continue;
332
+ const hash = await assetHash(ctx, url);
333
+ if (hash) hashes.set(url, hash);
334
+ }
335
+ html = versionHtmlRefs(html, (url) => hashes.get(url) ?? null);
336
+ return { html, images };
337
+ }
338
+
339
+ /**
340
+ * Content hash of the output file a site-absolute URL names, via the node
341
+ * that owns it; null when nothing ursa builds lives there.
342
+ */
343
+ async function assetHash(ctx, url) {
344
+ if (url === "/public/react-runtime.js") return env.jsonOnly ? null : (await ctx.get(nodeId("reactRuntime"))).hash;
345
+ if (url.startsWith("/public/")) {
346
+ const rel = url.slice("/public/".length);
347
+ const assets = await ctx.get(nodeId("metaAssets"));
348
+ if (assets.byRel[rel]) return (await ctx.get(nodeId("metaAsset", rel))).hash;
349
+ return null;
350
+ }
351
+ if (!url.startsWith("/") || url.startsWith("//")) return null;
352
+ let rel = url.replace(/^\//, "");
353
+ try {
354
+ rel = decodeURIComponent(rel);
355
+ } catch {
356
+ // keep as written
357
+ }
358
+ if (IMAGE_EXTENSIONS.test(rel)) return (await ctx.get(nodeId("imageInfo", rel)))?.hash ?? null;
359
+ if (isMedia(rel)) return (await ctx.get(nodeId("staticAsset", rel)))?.hash ?? null;
360
+ return null;
361
+ }
362
+
363
+ /** `?v=<hash>` on <link href>, <script src> and <img src> whose target is known. */
364
+ function versionHtmlRefs(html, lookup) {
365
+ const ver = (before, url, after) => {
366
+ if (url.includes("?")) return before + url + after;
367
+ const hash = lookup(url);
368
+ return hash ? `${before}${url}?v=${hash}${after}` : before + url + after;
369
+ };
370
+ html = html.replace(/(<link[^>]+href=["'])([^"']+\.css)(["'][^>]*>)/gi, (_, b, u, a) => ver(b, u, a));
371
+ html = html.replace(/(<script[^>]+src=["'])([^"']+\.js)(["'][^>]*>)/gi, (_, b, u, a) => ver(b, u, a));
372
+ html = html.replace(/(<img[^>]+src=["'])([^"']+\.(?:jpg|jpeg|png|gif|webp|svg|ico))(["'][^>]*>)/gi, (_, b, u, a) => ver(b, u, a));
373
+ return html;
374
+ }
375
+
376
+ /** Fill a template. A function replacer: `$&` in a document body must not be interpreted. */
377
+ function fillTemplate(template, replacements) {
378
+ const pattern = /\$\{(title|menu|meta|transformedMetadata|body|styleLink|customScript|searchIndex|footer)\}/g;
379
+ return template.replace(pattern, (match) => replacements[match] ?? match);
380
+ }
381
+
382
+ /** Body attributes: menu position, custom menu path, build id (for JSON fetch cache-busting). */
383
+ function bodyAttributes(html, customMenuInfo) {
384
+ const attrs = [];
385
+ if (customMenuInfo) attrs.push(`data-custom-menu="${customMenuInfo.menuJsonPath}"`);
386
+ attrs.push(`data-menu-position="${customMenuInfo?.menuPosition || "top"}"`);
387
+ attrs.push(`data-build="${env.session.buildId}"`);
388
+ return html.replace(/<body([^>]*)>/, `<body$1 ${attrs.join(" ")}>`);
389
+ }
390
+
391
+ /** Assemble a page from a body and write it. Shared by documents, auto-indices and listings. */
392
+ async function assemblePage(ctx, {
393
+ templateName, dirRel, docUrlPath, title, meta, body, transformedMetadata = "",
394
+ hydrationScript = "", useFolderAssets = true,
395
+ }) {
396
+ const templates = await ctx.get(nodeId("templates"));
397
+ if (!templates[templateName]) {
398
+ throw new Error(`Template not found. Requested: "${templateName}". Available templates: ${Object.keys(templates).join(", ") || "none"}`);
399
+ }
400
+ const template = await ctx.get(nodeId("metaBundle", templateName));
401
+ let styleLink = "";
402
+ let customScript = "";
403
+ if (useFolderAssets) {
404
+ const css = await ctx.get(nodeId("cssBundle", dirRel));
405
+ const js = await ctx.get(nodeId("jsBundle", dirRel));
406
+ if (css) styleLink = `<link rel="stylesheet" href="${css.url}" />`;
407
+ if (js) customScript = `<script src="${js.url}"></script>`;
408
+ }
409
+ if (hydrationScript) customScript = customScript ? customScript + "\n" + hydrationScript : hydrationScript;
410
+ const menu = await ctx.get(nodeId("menuHtml"));
411
+ const footer = await ctx.get(nodeId("footer"));
412
+ const customMenuInfo = await ctx.get(nodeId("customMenuFor", dirRel));
413
+
414
+ let html = fillTemplate(template, {
415
+ "${title}": title,
416
+ "${menu}": menu,
417
+ "${meta}": meta,
418
+ "${transformedMetadata}": transformedMetadata,
419
+ "${body}": body,
420
+ "${styleLink}": styleLink,
421
+ "${customScript}": customScript,
422
+ "${searchIndex}": "[]", // Placeholder - search index written separately as JSON file
423
+ "${footer}": footer,
424
+ });
425
+ html = bodyAttributes(html, customMenuInfo);
426
+ html = resolveRelativeUrls(html, docUrlPath);
427
+ html = await resolveLinks(ctx, html, docUrlPath);
428
+ return finishAssets(ctx, html, docUrlPath);
429
+ }
430
+
431
+ // -------------------------------------------------------------------------
432
+ // Node families
433
+ // -------------------------------------------------------------------------
434
+
435
+ const families = {
436
+ // ----- Site-wide -------------------------------------------------------
437
+
438
+ /**
439
+ * What participates in the build: a projection of directory listings (plus
440
+ * the whitelist/exclude files and each folder's config.json). Changes only
441
+ * when a name appears, disappears or changes kind.
442
+ */
443
+ documentSet: () => async (ctx) => {
444
+ const outputInside = output.startsWith(source + "/") ? output : null;
445
+ const includeFilter = process.env.INCLUDE_FILTER
446
+ ? (fileName) => fileName.match(process.env.INCLUDE_FILTER)
447
+ : () => true;
448
+ const excludeFilter = env.exclude
449
+ ? createExcludeFilter(await parseExcludeOption(env.exclude, source + "/"), source + "/")
450
+ : () => true;
451
+ const whitelistFilter = env.whitelist ? await createWhitelistFilter(env.whitelist, source) : () => true;
452
+
453
+ const files = [];
454
+ const dirs = [];
455
+ const walk = async (dirRel) => {
456
+ const dirAbs = abs(dirRel);
457
+ for (const entry of await ctx.listDir(dirAbs)) {
458
+ const rel = dirRel ? `${dirRel}/${entry.name}` : entry.name;
459
+ const entryAbs = join(dirAbs, entry.name);
460
+ if (outputInside && entryAbs === outputInside) continue;
461
+ if (isHiddenOrSystemPath(entryAbs, source)) continue;
462
+ if (entry.kind === "dir") {
463
+ if (isFolderSelfHidden(entryAbs)) continue;
464
+ if (!excludeFilter(entryAbs + "/") || !includeFilter(entryAbs)) continue;
465
+ dirs.push(rel);
466
+ await walk(rel);
467
+ } else if (entry.kind === "file") {
468
+ if (!includeFilter(entryAbs) || !excludeFilter(entryAbs) || !whitelistFilter(entryAbs)) continue;
469
+ files.push(rel);
470
+ }
471
+ }
472
+ };
473
+ await walk("");
474
+ files.sort();
475
+ dirs.sort();
476
+
477
+ const articles = files.filter((f) => isArticle(f));
478
+ const html = files.filter((f) => isHandwrittenHtml(f));
479
+ const images = files.filter((f) => IMAGE_EXTENSIONS.test(f));
480
+ const media = files.filter((f) => isMedia(f));
481
+ const dirsWithDocuments = new Set();
482
+ for (const f of [...articles, ...html]) {
483
+ let d = dirname(f);
484
+ while (d && d !== ".") {
485
+ dirsWithDocuments.add(d);
486
+ d = dirname(d);
487
+ }
488
+ }
489
+ const value = { files, dirs, articles, html, images, media, dirsWithDocuments: [...dirsWithDocuments].sort() };
490
+ attachSets(value);
491
+ return value;
492
+ },
493
+
494
+ /** Canonical-URL map for link resolution. Changes only on add/remove/rename. */
495
+ validPaths: () => async (ctx) => {
496
+ const set = await ctx.get(nodeId("documentSet"));
497
+ const map = buildValidPaths(
498
+ [...set.articles, ...set.html].map((r) => "/" + r),
499
+ "",
500
+ set.dirs.map((r) => "/" + r),
501
+ { dirsWithDocuments: new Set(set.dirsWithDocuments) }
502
+ );
503
+ const value = { entries: [...map.entries()] };
504
+ Object.defineProperty(value, "map", { value: map, enumerable: false });
505
+ return value;
506
+ },
507
+
508
+ /** Canonical `.html` path for one normalized href, or null. One node per href seen. */
509
+ linkResolution: (normalized) => async (ctx) => {
510
+ const vp = await ctx.get(nodeId("validPaths"));
511
+ return resolveNormalizedHref(normalized, vp.map);
512
+ },
513
+
514
+ templates: () => async () => getTemplates(meta),
515
+
516
+ /** Every file under meta/shared and the template folders (minus index.html), by public path. */
517
+ metaAssets: () => async (ctx) => {
518
+ const byRel = {};
519
+ const templatesDir = join(meta, "templates");
520
+ const copyDir = async (dirAbs, prefix, exclude = []) => {
521
+ for (const entry of await ctx.listDir(dirAbs)) {
522
+ if (exclude.includes(entry.name)) continue;
523
+ const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
524
+ if (entry.kind === "dir") await copyDir(join(dirAbs, entry.name), rel);
525
+ else if (entry.kind === "file") byRel[rel] = join(dirAbs, entry.name);
526
+ }
527
+ };
528
+ if (ctx.exists(templatesDir)) {
529
+ await copyDir(join(meta, "shared"), "");
530
+ for (const entry of await ctx.listDir(templatesDir)) {
531
+ if (entry.kind === "dir") await copyDir(join(templatesDir, entry.name), "", ["index.html"]);
532
+ }
533
+ // Files at the meta root are not part of any template
534
+ const orphans = [];
535
+ for (const entry of await ctx.listDir(meta)) {
536
+ if (entry.name === "templates" || entry.name === "shared") continue;
537
+ orphans.push(entry.name);
538
+ }
539
+ if (orphans.length > 0) {
540
+ warn("meta-orphans",
541
+ `⚠️ ${orphans.length} file(s)/folder(s) in the meta directory are not in meta/templates/ or meta/shared/ and won't be included: ${orphans.slice(0, 10).join(", ")}${orphans.length > 10 ? ", …" : ""}`);
542
+ }
543
+ } else {
544
+ // Legacy flat layout: everything but HTML
545
+ const walkLegacy = async (dirAbs, prefix) => {
546
+ for (const entry of await ctx.listDir(dirAbs)) {
547
+ const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
548
+ if (entry.kind === "dir") await walkLegacy(join(dirAbs, entry.name), rel);
549
+ else if (entry.kind === "file" && !entry.name.endsWith(".html")) byRel[rel] = join(dirAbs, entry.name);
550
+ }
551
+ };
552
+ await walkLegacy(meta, "");
553
+ }
554
+ const rels = Object.keys(byRel).sort();
555
+ return { rels, byRel };
556
+ },
557
+
558
+ /** One copied meta asset in output/public. */
559
+ metaAsset: (rel) => async (ctx) => {
560
+ const assets = await ctx.get(nodeId("metaAssets"));
561
+ const src = assets.byRel[rel];
562
+ if (!src) return null;
563
+ let content = await ctx.read(src, null);
564
+ if (rel.endsWith(".css")) {
565
+ // Version url() targets that are meta assets (fonts, images); never other stylesheets
566
+ const css = content.toString("utf8");
567
+ const urls = collectCssUrls(css);
568
+ const hashes = new Map();
569
+ for (const u of urls) {
570
+ const target = metaUrlToRel(u, dirname(rel));
571
+ if (target && target !== rel && !target.endsWith(".css") && assets.byRel[target]) {
572
+ hashes.set(u, (await ctx.get(nodeId("metaAsset", target))).hash);
573
+ }
574
+ }
575
+ content = versionCssUrls(css, (u) => hashes.get(u) ?? null);
576
+ } else if (rel.endsWith(".js")) {
577
+ content = rewriteJsonFetches(content.toString("utf8"));
578
+ }
579
+ const hash = await writeOutput(ctx, posix.join("public", rel), content);
580
+ return { url: `/public/${rel}`, hash };
581
+ },
582
+
583
+ /**
584
+ * A template with its `/public/` CSS and JS references bundled into one
585
+ * file each; owns the bundles. The value is the rewritten template HTML,
586
+ * whose bundle URLs carry `?v=<content hash>` — so it changes exactly when
587
+ * a page using the template must be rewritten.
588
+ */
589
+ metaBundle: (templateName) => async (ctx) => {
590
+ const templates = await ctx.get(nodeId("templates"));
591
+ const t = templates[templateName];
592
+ if (!t) throw new Error(`Template not found: "${templateName}"`);
593
+ const templateHtml = typeof t === "string" ? t : t.html;
594
+ const templateDir = typeof t === "string" ? meta : t.dir || meta;
595
+ const assets = parseTemplateAssets(templateHtml);
596
+ if (assets.cssFiles.length === 0 && assets.jsFiles.length === 0) return templateHtml;
597
+
598
+ const metaAssets = await ctx.get(nodeId("metaAssets"));
599
+ const urls = {};
600
+ let cssOk = false;
601
+ if (assets.cssFiles.length > 0) {
602
+ const cssPaths = assets.cssFiles.map((p) => resolveMetaAssetPath(p, templateDir, meta)).filter(Boolean);
603
+ if (cssPaths.length > 0) {
604
+ let css = await bundleCssContent(cssPaths, { minify: true });
605
+ const hashes = new Map();
606
+ for (const u of collectCssUrls(css)) {
607
+ const target = metaUrlToRel(u, "");
608
+ if (target && !target.endsWith(".css") && metaAssets.byRel[target]) {
609
+ hashes.set(u, (await ctx.get(nodeId("metaAsset", target))).hash);
610
+ }
611
+ }
612
+ css = versionCssUrls(css, (u) => hashes.get(u) ?? null);
613
+ const rel = `public/${templateName}.bundle.css`;
614
+ const hash = await writeOutput(ctx, rel, css);
615
+ urls.cssUrl = `/${rel}?v=${hash}`;
616
+ cssOk = true;
617
+ }
618
+ }
619
+ let jsOk = false;
620
+ if (assets.jsFiles.length > 0) {
621
+ const jsPaths = assets.jsFiles.map((p) => resolveMetaAssetPath(p, templateDir, meta)).filter(Boolean);
622
+ if (jsPaths.length > 0) {
623
+ const { success, code } = await bundleJsContent(jsPaths, { minify: true, minifySyntax: true });
624
+ if (success) {
625
+ const rel = `public/${templateName}.bundle.js`;
626
+ const hash = await writeOutput(ctx, rel, rewriteJsonFetches(code));
627
+ urls.jsUrl = `/${rel}?v=${hash}`;
628
+ jsOk = true;
629
+ }
630
+ }
631
+ }
632
+ // Only rewrite the tags whose bundle exists; a JS syntax error keeps the
633
+ // individual <script> tags so one broken file does not break them all
634
+ const toRewrite = { ...assets, cssFiles: cssOk ? assets.cssFiles : [], jsFiles: jsOk ? assets.jsFiles : [] };
635
+ return rewriteTemplateWithBundles(templateHtml, templateName, toRewrite, urls);
636
+ },
637
+
638
+ /** React + ReactDOM bundled for MDX hydration; a function of ursa's version only. */
639
+ reactRuntime: () => async (ctx) => {
640
+ ctx.constant("ursa-version");
641
+ const result = await esbuild.build({
642
+ stdin: {
643
+ contents: `
644
+ import React from 'react';
645
+ import * as ReactDOM from 'react-dom';
646
+ import { hydrateRoot, createRoot } from 'react-dom/client';
647
+ import * as _jsx_runtime from 'react/jsx-runtime';
648
+ window.React = React;
649
+ window.ReactDOM = { ...ReactDOM, hydrateRoot, createRoot };
650
+ window._jsx_runtime = _jsx_runtime;
651
+ window.__ursaReactRuntime = ${JSON.stringify(REACT_RUNTIME_MARKER)};
652
+ `,
653
+ resolveDir: dirname(new URL(import.meta.url).pathname),
654
+ loader: "js",
655
+ },
656
+ bundle: true,
657
+ format: "iife",
658
+ platform: "browser",
659
+ target: "es2020",
660
+ minify: true,
661
+ write: false,
662
+ });
663
+ const code = result.outputFiles[0].text;
664
+ const hash = await writeOutput(ctx, "public/react-runtime.js", code);
665
+ return { hash };
666
+ },
667
+
668
+ /**
669
+ * Footer HTML. Build id, timestamp and git hash are fixed for the session
670
+ * (§7) and excluded from the node's fingerprint (see `footerFingerprint`):
671
+ * a new `generate` run must not rewrite every page just to stamp a new
672
+ * build id into pages whose inputs did not change.
673
+ */
674
+ footer: () => async () =>
675
+ getFooter(source + "/", source, env.session.buildId, { now: env.session.now, gitHash: env.session.gitHash }),
676
+
677
+ /** Embedded in every JSON output. */
678
+ ursaMetadata: () => async (ctx) => {
679
+ ctx.constant("ursa-version");
680
+ let docVersion = "unknown";
681
+ for (const p of [join(source, "package.json"), join(dirname(source), "package.json")]) {
682
+ if (ctx.exists(p)) {
683
+ try {
684
+ const pkg = JSON.parse(await ctx.read(p));
685
+ if (pkg.version) {
686
+ docVersion = pkg.version;
687
+ break;
688
+ }
689
+ } catch {
690
+ // try the next
691
+ }
692
+ }
693
+ }
694
+ return { ursaVersion: getUrsaVersion(), docVersion };
695
+ },
696
+
697
+ /** The auto-menu: full tree (owns menu-data.json) plus the root-level markup. */
698
+ menuData: () => async (ctx) => {
699
+ const set = await ctx.get(nodeId("documentSet"));
700
+ const vp = await ctx.get(nodeId("validPaths"));
701
+ await preloadFrontmatter(ctx, set.articles, { nonDocuments: set.files.filter((f) => !set.articleSet.has(f)) });
702
+ const result = await getAutomenu(source, vp.map);
703
+ if (!env.jsonOnly) await writeOutput(ctx, "public/menu-data.json", JSON.stringify(result.menuData));
704
+ return { html: result.html, menuData: result.menuData };
705
+ },
706
+
707
+ /** Root-level menu markup inlined in every page; moves only when the root level changes. */
708
+ menuHtml: () => async (ctx) => {
709
+ const { html } = await ctx.get(nodeId("menuData"));
710
+ return renderFile({ fileContents: html, type: ".md" });
711
+ },
712
+
713
+ /** Directories holding a custom menu file. */
714
+ customMenus: () => async (ctx) => {
715
+ const set = await ctx.get(nodeId("documentSet"));
716
+ const dirs = new Set();
717
+ for (const f of set.files) {
718
+ if (MENU_FILE_NAMES.includes(basename(f))) dirs.add(dirRelOf(f));
719
+ }
720
+ return [...dirs].sort();
721
+ },
722
+
723
+ /** One custom menu's JSON. */
724
+ customMenu: (menuDirRel) => async (ctx) => {
725
+ const menuDir = abs(menuDirRel);
726
+ const info = findCustomMenu(menuDir, source);
727
+ if (!info || relOf(info.menuDir) !== menuDirRel) return null;
728
+ const below = await ctx.get(nodeId("dirSet", menuDirRel));
729
+ await preloadFrontmatter(ctx, below.articles, { nonDocuments: below.files.filter((f) => !isArticle(f)) });
730
+ const { frontmatter, body } = extractMenuFrontmatter(info.content);
731
+ const autoGenerate = frontmatter["auto-generate-menu"] === true || frontmatter["auto-generate-menu"] === "true";
732
+ const menuPosition = frontmatter["menu-position"] || "top";
733
+ const depth = parseInt(frontmatter["menu-depth"], 10) || 10;
734
+ const menuData = autoGenerate
735
+ ? combineAutoAndManualMenu(body, info.menuDir, source, depth)
736
+ : parseCustomMenu(body, info.menuDir, source);
737
+ const rel = `public/custom-menu-${menuId(menuDirRel)}.json`;
738
+ const hash = env.jsonOnly ? null : await writeOutput(ctx, rel, JSON.stringify({ menuData, menuPosition }));
739
+ return { url: "/" + rel, hash, menuPosition };
740
+ },
741
+
742
+ /** The custom menu a folder's pages use: the nearest menu file up the tree, or null. */
743
+ customMenuFor: (dirRel) => async () => {
744
+ const info = findCustomMenu(abs(dirRel), source);
745
+ if (!info) return null;
746
+ const { frontmatter } = extractMenuFrontmatter(info.content);
747
+ const menuDirRel = relOf(info.menuDir);
748
+ return {
749
+ menuJsonPath: `/public/custom-menu-${menuId(menuDirRel)}.json`,
750
+ menuDir: menuDirRel,
751
+ menuPosition: frontmatter["menu-position"] || "top",
752
+ };
753
+ },
754
+
755
+ /**
756
+ * The folder's inherited stylesheets bundled into public/<folder>.bundle.css.
757
+ * Value: {url} with `?v=<hash>`, or null when the chain is empty. The chain
758
+ * is a set of recorded lookups, so adding a style.css anywhere above the
759
+ * folder is observed.
760
+ */
761
+ cssBundle: (dirRel) => async (ctx) => {
762
+ const paths = await findAllStyleCss(abs(dirRel), source);
763
+ if (paths.length === 0) return null;
764
+ let css = await bundleCssContent(paths, { minify: true, rebaseUrls: true, sourceDir: source });
765
+ const hashes = new Map();
766
+ for (const u of collectCssUrls(css)) {
767
+ const hash = await assetHash(ctx, u);
768
+ if (hash) hashes.set(u, hash);
769
+ }
770
+ css = versionCssUrls(css, (u) => hashes.get(u) ?? null);
771
+ const rel = `public/${bundleName(dirRel)}.bundle.css`;
772
+ const hash = await writeOutput(ctx, rel, css);
773
+ return { url: `/${rel}?v=${hash}` };
774
+ },
775
+
776
+ jsBundle: (dirRel) => async (ctx) => {
777
+ const paths = await findAllScriptJs(abs(dirRel), source);
778
+ if (paths.length === 0) return null;
779
+ const { success, code } = await bundleJsContent(paths, { minify: true, minifySyntax: true });
780
+ if (!success) return null;
781
+ const rel = `public/${bundleName(dirRel)}.bundle.js`;
782
+ const hash = await writeOutput(ctx, rel, rewriteJsonFetches(code));
783
+ return { url: `/${rel}?v=${hash}` };
784
+ },
785
+
786
+ /**
787
+ * Cheap facts about an image: content hash, URLs, whether a preview will
788
+ * exist. Null when the file is absent — the miss is recorded, so a dead
789
+ * image link comes alive when the file appears (§8.4).
790
+ */
791
+ imageInfo: (rel) => async (ctx) => {
792
+ const path = abs(rel);
793
+ if (!isImageExtension(extname(rel))) return null;
794
+ if (!ctx.exists(path)) return null;
795
+ if (isFolderHidden(dirname(path), source) || isHiddenOrSystemPath(path, source)) return null;
796
+ const buf = await ctx.read(path, null);
797
+ const hash = hashBytes(buf);
798
+ const preview = await willHavePreview(path);
799
+ const original = urlOf(rel);
800
+ return {
801
+ original,
802
+ preview: preview ? urlOf(posix.join(dirRelOf(rel), getPreviewFilename(basename(rel)))) : original,
803
+ willPreview: preview,
804
+ hash,
805
+ };
806
+ },
807
+
808
+ /** The original image copied through. */
809
+ imageCopy: (rel) => async (ctx) => {
810
+ const info = await ctx.get(nodeId("imageInfo", rel));
811
+ if (!info) return null;
812
+ const hash = await writeOutput(ctx, rel, await ctx.read(abs(rel), null));
813
+ return { hash };
814
+ },
815
+
816
+ /** The WebP preview (expensive; scheduled after pages). */
817
+ imagePreview: (rel) => async (ctx) => {
818
+ const info = await ctx.get(nodeId("imageInfo", rel));
819
+ if (!info || !info.willPreview) return null;
820
+ const buf = await renderPreview(abs(rel));
821
+ if (!buf) return null;
822
+ const hash = await writeOutput(ctx, info.preview.replace(/^\//, ""), buf);
823
+ return { hash };
824
+ },
825
+
826
+ /** Fonts, audio, video, PDFs, archives: copied through. */
827
+ staticAsset: (rel) => async (ctx) => {
828
+ const path = abs(rel);
829
+ if (!ctx.exists(path)) return null;
830
+ if (isFolderHidden(dirname(path), source) || isHiddenOrSystemPath(path, source)) return null;
831
+ const buf = await ctx.read(path, null);
832
+ const hash = await writeOutput(ctx, rel, buf);
833
+ return { url: urlOf(rel), hash };
834
+ },
835
+
836
+ // ----- Per document ----------------------------------------------------
837
+
838
+ /** Frontmatter projection: a body edit leaves it unchanged. */
839
+ docMeta: (rel) => async (ctx) => {
840
+ const raw = await ctx.read(abs(rel));
841
+ let meta = null;
842
+ try {
843
+ meta = extractMetadata(raw);
844
+ } catch (e) {
845
+ warn(`frontmatter:${rel}`, `⚠️ ${rel}: could not parse frontmatter: ${e.message}`);
846
+ }
847
+ return {
848
+ meta,
849
+ isMetadataOnly: /\.(md|mdx)$/i.test(rel) && isMetadataOnly(raw),
850
+ autoIndex: getAutoIndexConfig(meta),
851
+ template: meta?.template || null,
852
+ hydrate: meta?.hydrate === true,
853
+ };
854
+ },
855
+
856
+ /** Rendered body HTML (plus hydration script), before the template. */
857
+ bodyHtml: (rel) => async (ctx) => {
858
+ const path = abs(rel);
859
+ const raw = await ctx.read(path);
860
+ const type = extname(rel);
861
+ const base = basename(rel, type);
862
+ const dir = dirWithSlash(rel);
863
+ let meta = null;
864
+ try {
865
+ meta = extractMetadata(raw);
866
+ } catch {
867
+ meta = null;
868
+ }
869
+ const title = titleOf(rel);
870
+ const shouldHydrate = type === ".mdx" && meta?.hydrate === true;
871
+
872
+ const renderResult = await renderFileAsync({
873
+ fileContents: raw,
874
+ type,
875
+ dirname: dir,
876
+ basename: base,
877
+ filePath: path,
878
+ sourceRoot: source,
879
+ useWorker: true,
880
+ hydrate: shouldHydrate,
881
+ });
882
+ let body;
883
+ let hydrationScript = "";
884
+ if (typeof renderResult === "object" && renderResult !== null) {
885
+ body = renderResult.html;
886
+ hydrationScript = renderResult.hydrationScript || "";
887
+ // Every module esbuild loaded is an input of this document
888
+ for (const input of renderResult.inputs ?? []) {
889
+ try {
890
+ await ctx.read(input, null);
891
+ } catch {
892
+ // recorded as missing; a later creation re-renders
893
+ }
894
+ }
895
+ if (renderResult.failed) {
896
+ // Resolution failed somewhere: watch the folders an import could
897
+ // appear in so creating the missing component re-renders this page
898
+ await ctx.listDir(dirname(path));
899
+ for (const d of renderResult.componentDirs ?? []) await ctx.listDir(d);
900
+ }
901
+ } else {
902
+ body = renderResult;
903
+ }
904
+
905
+ // Inject default H1 if body doesn't start with one
906
+ if (!body || !body.trimStart().startsWith("<h1")) {
907
+ const h1Title = meta?.title || title;
908
+ body = `<h1>${h1Title}</h1>\n` + (body || "");
909
+ }
910
+
911
+ // Breadcrumbs before the H1 (folder labels come from docMeta projections)
912
+ await preloadFrontmatter(ctx, ancestorIndexDocs(ctx, dirRelOf(rel)));
913
+ const breadcrumbs = generateBreadcrumbs(dir, base, meta, source);
914
+ if (breadcrumbs) body = breadcrumbs + body;
915
+
916
+ // Frontmatter table after the first H1 (markdown only)
917
+ if ((type === ".md" || type === ".mdx") && meta) {
918
+ body = injectFrontmatterTable(body, meta);
919
+ }
920
+
921
+ // `generate-auto-index: true` renders a listing of the folder's source tree
922
+ const autoIndexConfig = getAutoIndexConfig(meta);
923
+ if (base === "index" && meta && autoIndexConfig.enabled) {
924
+ await preloadFrontmatter(ctx, await docsBelow(ctx, dirRelOf(rel), autoIndexConfig.depth + 1));
925
+ const autoIndexHtml = await generateAutoIndexHtmlFromSource(dirname(path), autoIndexConfig.depth);
926
+ if (autoIndexHtml) {
927
+ body = autoIndexConfig.position === "bottom" ? body + "\n" + autoIndexHtml : autoIndexHtml + "\n" + body;
928
+ }
929
+ }
930
+
931
+ return { body, hydrationScript, meta, title, type, base, dir };
932
+ },
933
+
934
+ /**
935
+ * The page: owns `<path>.html`, and the folder's `index.html` too when this
936
+ * document is the folder's index by promotion (§8.3). Writes nothing when
937
+ * another source owns its output path (§8.2).
938
+ */
939
+ pageHtml: (rel) => async (ctx) => {
940
+ if (env.jsonOnly) return null;
941
+ const outRel = outputPathFor(rel);
942
+ const owner = await ctx.get(nodeId("outputOwner", outRel));
943
+ if (owner !== rel) {
944
+ if (owner && owner !== AUTO_INDEX) {
945
+ warn(`shadow:${outRel}`, `⚠️ ${rel} is not rendered: ${outRel} is produced by ${owner}`);
946
+ }
947
+ return { shadowedBy: owner };
948
+ }
949
+ const rendered = await ctx.get(nodeId("bodyHtml", rel));
950
+ const { body, hydrationScript, meta, title } = rendered;
951
+ const dirRel = dirRelOf(rel);
952
+ const docUrlPath = "/" + outRel;
953
+ const templateName = meta?.template || DEFAULT_TEMPLATE_NAME;
954
+
955
+ // Lazy: only load transformMetadata.js when the template uses it
956
+ const templates = await ctx.get(nodeId("templates"));
957
+ const templateHtml = templates[templateName]?.html ?? templates[templateName] ?? "";
958
+ const transformedMetadata = templateHtml.includes("${transformedMetadata}")
959
+ ? await getTransformedMetadata(dirname(abs(rel)), meta)
960
+ : "";
961
+
962
+ const { html, images } = await assemblePage(ctx, {
963
+ templateName,
964
+ dirRel,
965
+ docUrlPath,
966
+ title: meta?.title || title,
967
+ meta: JSON.stringify(meta),
968
+ body,
969
+ transformedMetadata,
970
+ hydrationScript,
971
+ });
972
+ const hash = await writeOutput(ctx, outRel, html);
973
+
974
+ // Folder-index promotion: also serve as <dir>/index.html when this document owns it
975
+ let promotedTo = null;
976
+ if (isIndexCandidate(rel)) {
977
+ const idxRel = indexOutputFor(dirRel);
978
+ if (idxRel !== outRel && (await ctx.get(nodeId("outputOwner", idxRel))) === rel) {
979
+ await writeOutput(ctx, idxRel, html);
980
+ promotedTo = idxRel;
981
+ }
982
+ }
983
+ return { hash, out: outRel, promotedTo, images, template: templateName };
984
+ },
985
+
986
+ /** The document's .json (and .xml) beside its page. */
987
+ docData: (rel) => async (ctx) => {
988
+ const outRel = outputPathFor(rel);
989
+ const owner = await ctx.get(nodeId("outputOwner", outRel));
990
+ if (owner !== rel) return { shadowedBy: owner };
991
+ const raw = await ctx.read(abs(rel));
992
+ const { body, meta, base } = await ctx.get(nodeId("bodyHtml", rel));
993
+ const type = extname(rel);
994
+ const sections = type === ".md" || type === ".mdx" ? extractSections(raw) : [];
995
+ const transformedMetadata = await getTransformedMetadata(dirname(abs(rel)), meta);
996
+ const ursaMetadata = await ctx.get(nodeId("ursaMetadata"));
997
+ const jsonObject = {
998
+ name: base,
999
+ url: "/" + outRel,
1000
+ contents: raw,
1001
+ bodyHtml: body,
1002
+ metadata: meta,
1003
+ sections,
1004
+ transformedMetadata,
1005
+ _ursa_metadata: ursaMetadata,
1006
+ };
1007
+ const jsonRel = outRel.replace(/\.html$/, ".json");
1008
+ const xmlRel = outRel.replace(/\.html$/, ".xml");
1009
+ const hash = await writeOutput(ctx, jsonRel, JSON.stringify(jsonObject));
1010
+ // The mode is an input: a full build after a JSON-only one must write the XML
1011
+ if (ctx.constant("json-only") !== "true") {
1012
+ await writeOutput(ctx, xmlRel, `<article>${o2x(jsonObject)}</article>`);
1013
+ } else if (existsSync(outAbs(xmlRel))) {
1014
+ ctx.own(xmlRel); // a full build's XML is left in place, not orphaned
1015
+ }
1016
+ return { hash };
1017
+ },
1018
+
1019
+ /** Per-document word counts for the full-text index. */
1020
+ docWords: (rel) => async (ctx) => {
1021
+ const raw = await ctx.read(abs(rel));
1022
+ return documentWordCounts({ title: titleOf(rel), content: raw });
1023
+ },
1024
+
1025
+ /** A hand-written .html copied through with link processing. */
1026
+ htmlPassthrough: (rel) => async (ctx) => {
1027
+ if (env.jsonOnly) return null;
1028
+ let html = await ctx.read(abs(rel));
1029
+ const docUrlPath = "/" + rel;
1030
+ html = resolveRelativeUrls(html, docUrlPath);
1031
+ html = await resolveLinks(ctx, html, docUrlPath);
1032
+ const finished = await finishAssets(ctx, html, docUrlPath);
1033
+ const hash = await writeOutput(ctx, rel, finished.html);
1034
+ return { hash, images: finished.images };
1035
+ },
1036
+
1037
+ // ----- Per directory ---------------------------------------------------
1038
+
1039
+ /**
1040
+ * Which source produces an output path, by the precedence list. Returns
1041
+ * the winning source (docroot-relative), AUTO_INDEX for a folder index
1042
+ * nobody claims, or null.
1043
+ */
1044
+ outputOwner: (outRel) => async (ctx) => {
1045
+ const set = await ctx.get(nodeId("documentSet"));
1046
+ for (const candidate of candidatesForOutput(outRel)) {
1047
+ if (candidate === AUTO_INDEX) return AUTO_INDEX;
1048
+ if (set.htmlSet.has(candidate)) return candidate;
1049
+ if (set.articleSet.has(candidate)) {
1050
+ // A frontmatter-only index supplies the folder's label; the auto-index is still the page
1051
+ if (isIndexBasename(basename(candidate, extname(candidate)))) {
1052
+ const info = await ctx.get(nodeId("docMeta", candidate));
1053
+ if (info.isMetadataOnly) continue;
1054
+ }
1055
+ return candidate;
1056
+ }
1057
+ }
1058
+ return null;
1059
+ },
1060
+
1061
+ /**
1062
+ * The document set restricted to one folder's subtree. A projection: it
1063
+ * changes only when something under that folder is added, removed or
1064
+ * renamed, so a document added elsewhere does not reach the folder's
1065
+ * listings.
1066
+ */
1067
+ dirSet: (dirRel) => async (ctx) => {
1068
+ const set = await ctx.get(nodeId("documentSet"));
1069
+ const prefix = dirRel ? dirRel + "/" : "";
1070
+ const under = (list) => list.filter((f) => f.startsWith(prefix));
1071
+ return { files: under(set.files), articles: under(set.articles), html: under(set.html), dirs: under(set.dirs) };
1072
+ },
1073
+
1074
+ /** `<dir>.json`: the folder's records, recursively. */
1075
+ dirIndexJson: (dirRel) => async (ctx) => {
1076
+ if (!dirRel) return null;
1077
+ const below = await ctx.get(nodeId("dirSet", dirRel));
1078
+ const records = [];
1079
+ for (const d of below.articles) {
1080
+ const outRel = outputPathFor(d);
1081
+ if ((await ctx.get(nodeId("outputOwner", outRel))) !== d) continue;
1082
+ const info = await ctx.get(nodeId("docMeta", d));
1083
+ records.push({ name: basename(d, extname(d)), url: "/" + outRel, metadata: info.meta });
1084
+ }
1085
+ const hash = await writeOutput(ctx, `${dirRel}.json`, JSON.stringify(records));
1086
+ return { hash };
1087
+ },
1088
+
1089
+ /** `<dir>.html`: a plain listing page, only when no document owns that path. */
1090
+ dirListingHtml: (dirRel) => async (ctx) => {
1091
+ if (!dirRel || env.jsonOnly) return null;
1092
+ const outRel = `${dirRel}.html`;
1093
+ const owner = await ctx.get(nodeId("outputOwner", outRel));
1094
+ if (owner) return { ownedBy: owner };
1095
+ const below = await ctx.get(nodeId("dirSet", dirRel));
1096
+ const items = below.files
1097
+ .map((f) => {
1098
+ const ext = extname(f);
1099
+ const href = "/" + (ext ? f.slice(0, -ext.length) : f) + ".html";
1100
+ return `<li><a href="${href}">${basename(f, ext)}</a></li>`;
1101
+ });
1102
+ const body = `<ul>${items.join("")}</ul>`;
1103
+ const { html } = await assemblePage(ctx, {
1104
+ templateName: DEFAULT_TEMPLATE_NAME,
1105
+ dirRel: dirRelOf(dirRel),
1106
+ docUrlPath: "/" + outRel,
1107
+ title: "Index",
1108
+ meta: "{}",
1109
+ body,
1110
+ useFolderAssets: false,
1111
+ });
1112
+ const hash = await writeOutput(ctx, outRel, html);
1113
+ return { hash };
1114
+ },
1115
+
1116
+ /** The generated index.html for a folder no document claims. */
1117
+ autoIndexPage: (dirRel) => async (ctx) => {
1118
+ if (env.jsonOnly) return null;
1119
+ const idxRel = indexOutputFor(dirRel);
1120
+ const owner = await ctx.get(nodeId("outputOwner", idxRel));
1121
+ if (owner !== AUTO_INDEX) return { ownedBy: owner };
1122
+ const dirAbs = abs(dirRel);
1123
+ await preloadFrontmatter(ctx, await indexDocsAround(ctx, dirRel));
1124
+ const listing = await generateAutoIndexHtmlFromSource(dirAbs, 1);
1125
+ if (!listing) return { empty: true };
1126
+
1127
+ const folderName = basename(dirAbs);
1128
+ const folderDisplayName = dirRel ? getFolderLabel(dirAbs, getFolderConfig(dirAbs), folderName) : "Home";
1129
+ await preloadFrontmatter(ctx, ancestorIndexDocs(ctx, dirRel));
1130
+ const breadcrumbHtml = generateBreadcrumbs(dirRel ? dirRel + "/" : "/", "index", null, source);
1131
+ const body = `${breadcrumbHtml}<h1>${folderDisplayName}</h1>\n${listing}`;
1132
+ const { html } = await assemblePage(ctx, {
1133
+ templateName: DEFAULT_TEMPLATE_NAME,
1134
+ dirRel,
1135
+ docUrlPath: "/" + idxRel,
1136
+ title: folderDisplayName,
1137
+ meta: "{}",
1138
+ body,
1139
+ });
1140
+ const hash = await writeOutput(ctx, idxRel, html);
1141
+ return { hash };
1142
+ },
1143
+
1144
+ // ----- Aggregates ------------------------------------------------------
1145
+
1146
+ searchIndex: () => async (ctx) => {
1147
+ const set = await ctx.get(nodeId("documentSet"));
1148
+ const entries = [];
1149
+ for (const d of set.articles) {
1150
+ const outRel = outputPathFor(d);
1151
+ if ((await ctx.get(nodeId("outputOwner", outRel))) !== d) continue;
1152
+ entries.push({ title: titleOf(d), path: outRel, url: "/" + outRel, content: "" });
1153
+ }
1154
+ const hash = await writeOutput(ctx, "public/search-index.json", JSON.stringify(entries));
1155
+ return { hash, entries: entries.length };
1156
+ },
1157
+
1158
+ fullTextIndex: () => async (ctx) => {
1159
+ const set = await ctx.get(nodeId("documentSet"));
1160
+ const docs = [];
1161
+ for (const d of set.articles) {
1162
+ const outRel = outputPathFor(d);
1163
+ if ((await ctx.get(nodeId("outputOwner", outRel))) !== d) continue;
1164
+ docs.push({ path: "/" + outRel, counts: await ctx.get(nodeId("docWords", d)) });
1165
+ }
1166
+ const index = mergeWordCounts(docs);
1167
+ const hash = await writeOutput(ctx, "public/fulltext-index.json", JSON.stringify(index));
1168
+ return { hash, words: Object.keys(index).length };
1169
+ },
1170
+
1171
+ /** Ten most recently edited documents, dated from git (or mtime). */
1172
+ recentActivity: () => async (ctx) => {
1173
+ const set = await ctx.get(nodeId("documentSet"));
1174
+ // Content changes are what move a document's date, so depend on each file
1175
+ const docs = [];
1176
+ for (const d of set.articles) {
1177
+ const outRel = outputPathFor(d);
1178
+ if ((await ctx.get(nodeId("outputOwner", outRel))) !== d) continue;
1179
+ await ctx.read(abs(d), null);
1180
+ docs.push(d);
1181
+ }
1182
+ const timestamps = await buildSourceTimestampIndex(source, { log: (m) => log(m) });
1183
+ const entries = [];
1184
+ for (const d of docs) {
1185
+ entries.push({ title: titleOf(d), url: "/" + outputPathFor(d), mtime: await timestamps.get(abs(d)) });
1186
+ }
1187
+ entries.sort((a, b) => b.mtime - a.mtime || (a.url < b.url ? -1 : a.url > b.url ? 1 : 0));
1188
+ const top10 = entries.slice(0, 10);
1189
+ const hash = await writeOutput(ctx, "public/recent-activity.json", JSON.stringify(top10));
1190
+ return { hash };
1191
+ },
1192
+ };
1193
+
1194
+ /** Per-family value fingerprints where the default (hash of the value) is wrong. */
1195
+ const fingerprints = {
1196
+ footer: footerFingerprint,
1197
+ };
1198
+
1199
+ return {
1200
+ /** Graph resolver: node id → definition. */
1201
+ resolve(id) {
1202
+ const { kind, key } = parseNodeId(id);
1203
+ const family = families[kind];
1204
+ if (!family) return null;
1205
+ return { fn: family(key), fingerprint: fingerprints[kind] };
1206
+ },
1207
+ families: Object.keys(families),
1208
+ };
1209
+ }
1210
+
1211
+ /**
1212
+ * The footer without its per-session build metadata line and git comment.
1213
+ * Only the parts that are functions of the source tree (footer.md, the doc
1214
+ * package.json) count as a change.
1215
+ */
1216
+ export function footerFingerprint(html) {
1217
+ const stable = String(html ?? "")
1218
+ .replace(/<div class="footer-meta">[\s\S]*?<\/div>/, "")
1219
+ .replace(/<!-- git: [^>]*-->/, "");
1220
+ return hashBytes(stable);
1221
+ }
1222
+
1223
+ // ---------------------------------------------------------------------------
1224
+ // Small helpers
1225
+ // ---------------------------------------------------------------------------
1226
+
1227
+ /** Sets for membership tests, attached non-enumerably so they stay out of the fingerprint. */
1228
+ function attachSets(set) {
1229
+ const define = (name, arr) => Object.defineProperty(set, name, { value: new Set(arr), enumerable: false });
1230
+ define("articleSet", set.articles);
1231
+ define("htmlSet", set.html);
1232
+ define("imageSet", set.images);
1233
+ define("mediaSet", set.media);
1234
+ define("dirSet", set.dirs);
1235
+ define("fileSet", set.files);
1236
+ return set;
1237
+ }
1238
+
1239
+ function bundleName(dirRel) {
1240
+ return dirRel.replace(/^\/+|\/+$/g, "").replace(/\//g, "-") || "root";
1241
+ }
1242
+
1243
+ function menuId(menuDirRel) {
1244
+ if (!menuDirRel) return "root";
1245
+ return menuDirRel.replace(/[\/\\]/g, "-").replace(/[^a-zA-Z0-9-]/g, "");
1246
+ }
1247
+
1248
+ /** Every url() target in a stylesheet (raw, as written). */
1249
+ function collectCssUrls(css) {
1250
+ const out = new Set();
1251
+ const re = /url\(\s*(['"]?)(?!data:)([^'"\)]+?)\1\s*\)/gi;
1252
+ let m;
1253
+ while ((m = re.exec(css)) !== null) {
1254
+ if (!m[2].includes("?") && !m[2].startsWith("#")) out.add(m[2]);
1255
+ }
1256
+ return [...out];
1257
+ }
1258
+
1259
+ /**
1260
+ * A url() written in meta CSS → the public-relative asset it names, or null
1261
+ * for anything external. `/public/x` is x; a relative path resolves against
1262
+ * the stylesheet's own public-relative directory (bundles live at the root).
1263
+ */
1264
+ function metaUrlToRel(url, cssDirRel) {
1265
+ if (/^(https?:)?\/\//i.test(url) || url.startsWith("data:")) return null;
1266
+ if (url.startsWith("/public/")) return url.slice("/public/".length);
1267
+ if (url.startsWith("/")) return null;
1268
+ return posix.normalize(posix.join(cssDirRel || "", url));
1269
+ }
1270
+