@kenjura/ursa 0.96.0 → 0.98.0

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