@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
@@ -1,8 +1,6 @@
1
1
  // Barrel file for build helpers
2
- export * from './cacheBust.js';
3
2
  export * from './batch.js';
4
3
  export * from './progress.js';
5
- export * from './watchCache.js';
6
4
  export * from './titleCase.js';
7
5
  export * from './excludeFilter.js';
8
6
  export * from './pathUtils.js';
@@ -1,8 +1,17 @@
1
1
  // Metadata transformation helpers for build
2
2
  import { join } from "path";
3
+ import { pathToFileURL } from "url";
4
+ import { existsSync, readFileSync, hashBytes } from "./tracedFs.js";
3
5
 
4
6
  /**
5
- * Get transformed metadata using custom or default transform function
7
+ * Get transformed metadata using custom or default transform function.
8
+ *
9
+ * `transformMetadata.js` is loaded with a dynamic `import()`, which Node
10
+ * caches for the life of the process. The specifier carries the file's
11
+ * content hash as a query so an edit takes effect in `serve` without a
12
+ * restart; reading the file to hash it is also what records it as an input
13
+ * of the document.
14
+ *
6
15
  * @param {string} dirname - Directory containing the file
7
16
  * @param {Object} metadata - Raw metadata object
8
17
  * @returns {Promise<string>} Transformed metadata string
@@ -12,11 +21,16 @@ export async function getTransformedMetadata(dirname, metadata) {
12
21
  const customTransformFnFilename = join(dirname, "transformMetadata.js");
13
22
  let transformFn = defaultTransformFn;
14
23
  try {
15
- const customTransformFn = (await import(customTransformFnFilename)).default;
16
- if (typeof customTransformFn === "function")
17
- transformFn = customTransformFn;
24
+ if (existsSync(customTransformFnFilename)) {
25
+ const source = readFileSync(customTransformFnFilename);
26
+ const url = pathToFileURL(customTransformFnFilename);
27
+ url.searchParams.set("v", hashBytes(source));
28
+ const customTransformFn = (await import(url.href)).default;
29
+ if (typeof customTransformFn === "function")
30
+ transformFn = customTransformFn;
31
+ }
18
32
  } catch (e) {
19
- // No custom transform found, use default
33
+ // No usable custom transform found, use default
20
34
  }
21
35
  try {
22
36
  return transformFn(metadata);
@@ -0,0 +1,497 @@
1
+ /**
2
+ * One incremental build pass (docs/SERVE.md §5), shared by `generate` and
3
+ * `serve`.
4
+ *
5
+ * 1. Refresh the leaves the watcher flagged (or every leaf on a warm start).
6
+ * 2. Pre-pass source mutation: document-template reconciliation.
7
+ * 3. Establish the document set; drop nodes whose source is gone and delete
8
+ * the files they owned.
9
+ * 4. Compute the dirty set (an upper bound) so clients can be told at once.
10
+ * 5. Build: viewed pages first, then every page and asset, then the
11
+ * expensive non-blocking outputs (previews, indices).
12
+ * 6. Delete orphaned outputs, persist the graph, report.
13
+ *
14
+ * Exactly one pass runs at a time; the caller (serve) serialises them.
15
+ */
16
+
17
+ import { basename, dirname, join, relative } from "path";
18
+ import { rm, unlink, readdir, rmdir } from "fs/promises";
19
+ import { existsSync } from "fs";
20
+ import { emptyDir } from "fs-extra";
21
+
22
+ import { BuildGraph, loadGraph, saveGraph, isLeafId } from "./graph.js";
23
+ import { hashBytes } from "./tracedFs.js";
24
+ import {
25
+ createSite, nodeId, parseNodeId,
26
+ DOC_KINDS, DIR_KINDS, INTERNAL_KINDS, SITE_KINDS,
27
+ } from "./site.js";
28
+ import { ARTICLE_EXT_RE, AUTO_INDEX, candidatesForOutput, isHandwrittenHtml } from "./precedence.js";
29
+ import { getUrsaDir, enforceCacheVersion } from "../contentHash.js";
30
+ import { getUrsaVersion } from "../ursaVersion.js";
31
+ import { getAndIncrementBuildId } from "../ursaConfig.js";
32
+ import { readGitHash } from "./footer.js";
33
+ import { reconcileAll, reconcileByTemplate, isInsideTemplatesFolder, TEMPLATES_FOLDER } from "../documentTemplates.js";
34
+ import { recurse } from "../recursive-readdir.js";
35
+ import { isHiddenOrSystemPath } from "../hiddenPaths.js";
36
+ import { terminateParserPool } from "../fileRenderer.js";
37
+
38
+ const DEFAULT_CONCURRENCY = parseInt(process.env.URSA_BATCH_SIZE || "50", 10);
39
+ /** The fingerprint of a node whose value is null (see graph.js defaultValueFingerprint). */
40
+ const NULL_FINGERPRINT = hashBytes("null");
41
+
42
+ /**
43
+ * Create a build: a graph plus the site's node definitions, ready to run passes.
44
+ *
45
+ * @param {object} opts
46
+ * @param {string} opts.source - Docroot
47
+ * @param {string} opts.meta - Meta directory
48
+ * @param {string} opts.output - Output directory
49
+ * @param {string|null} [opts.whitelist]
50
+ * @param {string|null} [opts.exclude]
51
+ * @param {boolean} [opts.clean] - Delete .ursa/ and empty the output first
52
+ * @param {boolean} [opts.jsonOnly]
53
+ * @param {boolean} [opts.explain] - Log, for each recomputed node, the input that moved
54
+ * @param {number} [opts.concurrency]
55
+ * @param {(msg: string) => void} [opts.log]
56
+ */
57
+ export async function createBuild({
58
+ source,
59
+ meta,
60
+ output,
61
+ whitelist = null,
62
+ exclude = null,
63
+ clean = false,
64
+ jsonOnly = false,
65
+ explain = false,
66
+ concurrency = DEFAULT_CONCURRENCY,
67
+ log = (m) => console.log(m),
68
+ }) {
69
+ source = source.replace(/\/+$/, "");
70
+ meta = meta.replace(/\/+$/, "");
71
+ output = output.replace(/\/+$/, "");
72
+
73
+ if (clean) {
74
+ const ursaDir = getUrsaDir(source);
75
+ log(`Clean build: deleting cache folder ${ursaDir}`);
76
+ await rm(ursaDir, { recursive: true, force: true });
77
+ log(`Clean build: clearing output directory ${output}`);
78
+ await emptyDir(output);
79
+ }
80
+
81
+ // A cache written by another ursa is discarded: the code that turned source
82
+ // into output changed, and the graph cannot see that through its leaves.
83
+ const stamp = await enforceCacheVersion(source);
84
+ if (stamp.reset) {
85
+ log(`Cache discarded: written by ursa ${stamp.previous ?? "(unstamped)"}, now running ${stamp.version}`);
86
+ }
87
+
88
+ // Orphan deletion: immediate while nodes are being removed before the
89
+ // pass's writes, deferred (and re-checked) during the pass itself.
90
+ let deferOrphans = false;
91
+ let orphanQueue = [];
92
+ let deletedCount = 0;
93
+ const deleteOutputs = async (rels) => {
94
+ for (const rel of rels) {
95
+ const path = join(output, rel);
96
+ try {
97
+ await unlink(path);
98
+ deletedCount++;
99
+ log(` 🗑 ${rel}`);
100
+ } catch {
101
+ // already gone
102
+ }
103
+ await pruneEmptyDirs(dirname(path), output);
104
+ }
105
+ };
106
+ const graph = new BuildGraph({
107
+ onOrphans: async (paths) => {
108
+ if (deferOrphans) orphanQueue.push(...paths);
109
+ else await deleteOutputs(paths);
110
+ },
111
+ });
112
+ graph.setRoots({ S: source, M: meta });
113
+
114
+ let warm = false;
115
+ if (!clean) {
116
+ warm = await loadGraph(source, graph);
117
+ if (warm) {
118
+ const s = graph.getStats();
119
+ log(`Build graph loaded: ${s.derivedNodes} nodes, ${s.leaves} leaves, ${s.edges} edges, ${s.owned} outputs`);
120
+ }
121
+ }
122
+ graph.setConst("ursa-version", getUrsaVersion());
123
+ graph.setConst("json-only", String(jsonOnly));
124
+
125
+ const session = {
126
+ buildId: getAndIncrementBuildId(source),
127
+ now: new Date(),
128
+ gitHash: readGitHash(source),
129
+ };
130
+
131
+ let writtenCount = 0;
132
+ const warned = new Set();
133
+ const env = {
134
+ source, meta, output, whitelist, exclude, jsonOnly, session,
135
+ log,
136
+ warn: (key, msg) => {
137
+ if (warned.has(key)) return;
138
+ warned.add(key);
139
+ console.warn(msg);
140
+ },
141
+ onWrite: () => { writtenCount++; },
142
+ };
143
+ const site = createSite(env);
144
+ graph.resolver(site.resolve);
145
+
146
+ let firstPass = true;
147
+
148
+ /**
149
+ * Run one pass.
150
+ * @param {object} [opts]
151
+ * @param {string[]} [opts.viewedOutputs] - Output paths (relative) clients are looking at, first
152
+ * @param {(dirty: Set<string>, changedLeaves: string[]) => void} [opts.onDirty] - Called once the dirty set is known
153
+ * @param {(id: string, err: Error|null) => void} [opts.onNodeDone] - Called as each root finishes
154
+ * @param {() => string[]} [opts.moreViewed] - Re-read viewed outputs between phases (§6.3)
155
+ * @param {boolean} [opts.rescan] - Re-check every known leaf instead of only those the
156
+ * watcher flagged (what a warm start does; for callers without a watcher)
157
+ */
158
+ async function runPass({ viewedOutputs = [], onDirty = null, onNodeDone = null, moreViewed = null, rescan = false } = {}) {
159
+ const t0 = Date.now();
160
+ const timings = {};
161
+ const time = (name, start) => { timings[name] = Date.now() - start; };
162
+ warned.clear();
163
+ writtenCount = 0;
164
+ deletedCount = 0;
165
+ orphanQueue = [];
166
+ deferOrphans = false;
167
+ graph.beginPass();
168
+
169
+ // 1. Leaves
170
+ let t = Date.now();
171
+ let changed = (firstPass && warm) || rescan ? await graph.scanLeaves() : await graph.refreshStale();
172
+ time("leaves", t);
173
+
174
+ // 2. Pre-pass: document templates (the only step that writes to the docroot)
175
+ t = Date.now();
176
+ const templateWrites = await reconcileTemplates(changed);
177
+ if (templateWrites.length > 0) {
178
+ for (const p of templateWrites) graph.invalidatePath(p);
179
+ changed = [...new Set([...changed, ...(await graph.refreshStale())])];
180
+ }
181
+ time("templates", t);
182
+
183
+ // 3. Document set and garbage collection (before any write: §8.5 case renames)
184
+ t = Date.now();
185
+ const set = await graph.demand(nodeId("documentSet"));
186
+ const customMenus = jsonOnly ? [] : await graph.demand(nodeId("customMenus"));
187
+ const metaAssets = jsonOnly ? { rels: [] } : await graph.demand(nodeId("metaAssets"));
188
+ const templates = jsonOnly ? {} : await graph.demand(nodeId("templates"));
189
+ await collectGarbage(set, customMenus, metaAssets, templates);
190
+ time("gc", t);
191
+
192
+ // 4. Dirty set: an upper bound, computed before any work
193
+ const dirty = graph.dependents(changed);
194
+ if (onDirty) onDirty(dirty, changed);
195
+ deferOrphans = true;
196
+
197
+ const failures = new Map();
198
+ const done = (id, err) => {
199
+ if (err) failures.set(id, err);
200
+ if (onNodeDone) onNodeDone(id, err);
201
+ };
202
+
203
+ // 5a. Viewed pages first
204
+ t = Date.now();
205
+ const viewedNodes = await nodesForOutputs(viewedOutputs, set);
206
+ if (viewedNodes.length > 0) {
207
+ await graph.build(viewedNodes, { concurrency, onDone: done });
208
+ }
209
+ time("viewed", t);
210
+
211
+ // 5b. Every page, then the assets they reference (a page may reference an
212
+ // image outside the document set — the whitelist case — which is only
213
+ // known once the page has been built)
214
+ t = Date.now();
215
+ const roots = pageRoots(set, customMenus, metaAssets, templates);
216
+ const laterViewed = moreViewed ? await nodesForOutputs(moreViewed(), set) : [];
217
+ await graph.build([...laterViewed, ...roots], { concurrency, onDone: done });
218
+ await graph.build(assetRoots(set), { concurrency, onDone: done });
219
+ time("pages", t);
220
+
221
+ // 5c. Expensive, non-blocking outputs
222
+ t = Date.now();
223
+ await graph.build(expensiveRoots(set), { concurrency, onDone: done });
224
+ time("indices", t);
225
+
226
+ // 6. Orphans, persistence
227
+ t = Date.now();
228
+ deferOrphans = false;
229
+ await flushOrphans();
230
+ await saveGraph(source, graph);
231
+ time("finish", t);
232
+
233
+ const computed = new Set(graph._computedThisPass);
234
+ const changedNodes = new Set(graph._changedThisPass);
235
+ graph.endPass();
236
+ firstPass = false;
237
+
238
+ const summary = {
239
+ changedLeaves: changed,
240
+ dirty,
241
+ computed,
242
+ changedNodes,
243
+ failures,
244
+ written: writtenCount,
245
+ deleted: deletedCount,
246
+ timings,
247
+ elapsed: Date.now() - t0,
248
+ viewedNodes,
249
+ };
250
+ report(summary);
251
+ return summary;
252
+ }
253
+
254
+ // -------------------------------------------------------------------------
255
+
256
+ /** Reconcile `_templates` instances. Returns docroot files written. */
257
+ async function reconcileTemplates(changedLeaves) {
258
+ const templatesDir = join(source, TEMPLATES_FOLDER);
259
+ if (!existsSync(templatesDir)) return [];
260
+ const changedTemplates = changedLeaves
261
+ .filter(isLeafId)
262
+ .map((id) => graph.leafPath(id))
263
+ .filter((p) => isInsideTemplatesFolder(relative(source, p)) || p.startsWith(templatesDir + "/"));
264
+ if (!firstPass && changedTemplates.length === 0) return [];
265
+
266
+ const allFiles = await recurse(source);
267
+ const articles = allFiles.filter((f) => ARTICLE_EXT_RE.test(f) && !isHiddenOrSystemPath(f, source));
268
+ let summary;
269
+ if (firstPass) {
270
+ summary = await reconcileAll(articles, allFiles, source);
271
+ } else {
272
+ summary = { initialized: 0, updated: 0, conflicts: 0, unchanged: 0, errors: 0, affectedPaths: new Set(), messages: [] };
273
+ for (const tpl of new Set(changedTemplates)) {
274
+ if (!existsSync(tpl) || !ARTICLE_EXT_RE.test(tpl)) continue;
275
+ const s = await reconcileByTemplate(tpl, articles, source);
276
+ for (const k of ["initialized", "updated", "conflicts", "unchanged", "errors"]) summary[k] += s[k];
277
+ for (const p of s.affectedPaths) summary.affectedPaths.add(p);
278
+ summary.messages.push(...s.messages);
279
+ }
280
+ }
281
+ if (summary.updated > 0 || summary.conflicts > 0 || summary.initialized > 0) {
282
+ log(
283
+ `📄 Document templates: ${summary.initialized} initialized, ${summary.updated} auto-merged, ` +
284
+ `${summary.conflicts} conflicts, ${summary.unchanged} unchanged, ${summary.errors} errors`
285
+ );
286
+ if (summary.conflicts > 0) {
287
+ console.warn(`\n⚠️ Template conflicts require manual resolution:`);
288
+ for (const msg of summary.messages) if (msg.includes("Conflict")) console.warn(` ${msg}`);
289
+ console.warn("");
290
+ }
291
+ }
292
+ if (summary.errors > 0) {
293
+ for (const msg of summary.messages) {
294
+ if (msg.includes("Error") || msg.includes("not found")) console.warn(` ⚠️ ${msg}`);
295
+ }
296
+ }
297
+ return [...summary.affectedPaths];
298
+ }
299
+
300
+ /**
301
+ * Remove nodes whose key left the document set (their outputs are deleted),
302
+ * then internal nodes nothing depends on any more.
303
+ */
304
+ async function collectGarbage(set, customMenus, metaAssets, templates = {}) {
305
+ const dirs = new Set(["", ...set.dirs]);
306
+ const templateNames = new Set(Object.keys(templates));
307
+ const menus = new Set(customMenus);
308
+ const metaRels = new Set(metaAssets.rels ?? []);
309
+ const referencedImages = () => {
310
+ const out = new Set();
311
+ for (const id of graph.rdeps.keys()) {
312
+ if (id.startsWith("imageInfo:") && graph.rdeps.get(id)?.size > 0) out.add(parseNodeId(id).key);
313
+ }
314
+ return out;
315
+ };
316
+ let referenced = referencedImages();
317
+ const keepKeyed = (id) => {
318
+ if (isLeafId(id)) return true;
319
+ const { kind, key } = parseNodeId(id);
320
+ if (SITE_KINDS.includes(kind) || INTERNAL_KINDS.includes(kind)) return true;
321
+ if (DOC_KINDS.includes(kind)) return set.articleSet.has(key);
322
+ if (DIR_KINDS.includes(kind)) return dirs.has(key);
323
+ if (kind === "htmlPassthrough") return set.htmlSet.has(key);
324
+ if (kind === "staticAsset") return set.mediaSet.has(key);
325
+ if (kind === "imageCopy" || kind === "imagePreview") return set.imageSet.has(key) || referenced.has(key);
326
+ if (kind === "customMenu") return menus.has(key);
327
+ return true;
328
+ };
329
+ await graph.gc(keepKeyed);
330
+ // Internal families: kept only while something depends on them
331
+ referenced = referencedImages();
332
+ await graph.gc((id) => {
333
+ if (isLeafId(id)) return true;
334
+ const { kind } = parseNodeId(id);
335
+ if (!INTERNAL_KINDS.includes(kind)) return true;
336
+ if (kind === "metaAsset") return metaRels.has(parseNodeId(id).key) || graph.rdeps.get(id)?.size > 0;
337
+ if (kind === "metaBundle") return templateNames.has(parseNodeId(id).key) || graph.rdeps.get(id)?.size > 0;
338
+ if (kind === "docMeta") return set.articleSet.has(parseNodeId(id).key) || graph.rdeps.get(id)?.size > 0;
339
+ return graph.rdeps.get(id)?.size > 0;
340
+ });
341
+ // imageCopy/imagePreview for images that were only referenced may now be unreferenced
342
+ await graph.gc((id) => {
343
+ if (isLeafId(id)) return true;
344
+ const { kind, key } = parseNodeId(id);
345
+ if (kind === "imageCopy" || kind === "imagePreview") return set.imageSet.has(key) || referenced.has(key);
346
+ return true;
347
+ });
348
+ }
349
+
350
+ /** Roots for phase 5b, in a stable order. */
351
+ function pageRoots(set, customMenus, metaAssets, templates) {
352
+ const ids = [];
353
+ if (jsonOnly) {
354
+ for (const d of set.articles) ids.push(nodeId("docData", d));
355
+ return ids;
356
+ }
357
+ for (const rel of metaAssets.rels) ids.push(nodeId("metaAsset", rel));
358
+ // Every template's bundles exist whether or not a page uses it yet
359
+ for (const t of Object.keys(templates).sort()) ids.push(nodeId("metaBundle", t));
360
+ ids.push(nodeId("reactRuntime"));
361
+ for (const d of set.articles) ids.push(nodeId("pageHtml", d));
362
+ for (const h of set.html) ids.push(nodeId("htmlPassthrough", h));
363
+ for (const dir of ["", ...set.dirs]) ids.push(nodeId("autoIndexPage", dir));
364
+ for (const dir of set.dirs) ids.push(nodeId("dirListingHtml", dir));
365
+ ids.push(nodeId("menuData"));
366
+ for (const d of customMenus) ids.push(nodeId("customMenu", d));
367
+ for (const d of set.articles) ids.push(nodeId("docData", d));
368
+ return ids;
369
+ }
370
+
371
+ /** Copied assets: images (in the set or referenced by a page) and media. */
372
+ function assetRoots(set) {
373
+ if (jsonOnly) return [];
374
+ const ids = [];
375
+ for (const img of imageRoots(set)) ids.push(nodeId("imageCopy", img));
376
+ for (const m of set.media) ids.push(nodeId("staticAsset", m));
377
+ return ids;
378
+ }
379
+
380
+ /** Roots for phase 5c. */
381
+ function expensiveRoots(set) {
382
+ const ids = [];
383
+ for (const dir of set.dirs) ids.push(nodeId("dirIndexJson", dir));
384
+ if (jsonOnly) return ids;
385
+ for (const img of imageRoots(set)) ids.push(nodeId("imagePreview", img));
386
+ ids.push(nodeId("searchIndex"), nodeId("fullTextIndex"), nodeId("recentActivity"));
387
+ return ids;
388
+ }
389
+
390
+ /** Images in the set, plus images pages reference that the set excludes (whitelist). */
391
+ function imageRoots(set) {
392
+ const out = new Set(set.images);
393
+ for (const id of graph.rdeps.keys()) {
394
+ if (!id.startsWith("imageInfo:") || !(graph.rdeps.get(id)?.size > 0)) continue;
395
+ // A referenced image that is known not to exist has nothing to copy
396
+ if (graph.fingerprints.get(id) === NULL_FINGERPRINT) continue;
397
+ out.add(parseNodeId(id).key);
398
+ }
399
+ return [...out].sort();
400
+ }
401
+
402
+ /**
403
+ * The node that owns an output path (relative to the output directory):
404
+ * from the persisted ownership table when known, else from precedence.
405
+ */
406
+ async function nodeForOutput(outRel, set) {
407
+ const known = graph.ownerOfPath(outRel);
408
+ if (known) return known;
409
+ if (!outRel.endsWith(".html")) return null;
410
+ const owner = await graph.demand(nodeId("outputOwner", outRel));
411
+ const dir = dirname(outRel) === "." ? "" : dirname(outRel);
412
+ if (owner === AUTO_INDEX) return nodeId("autoIndexPage", dir);
413
+ if (owner === null) {
414
+ // <dir>.html listing, if the path names a directory
415
+ const base = basename(outRel, ".html");
416
+ const dirRel = dir ? `${dir}/${base}` : base;
417
+ return set.dirSet.has(dirRel) ? nodeId("dirListingHtml", dirRel) : null;
418
+ }
419
+ if (isHandwrittenHtml(owner)) return nodeId("htmlPassthrough", owner);
420
+ return nodeId("pageHtml", owner);
421
+ }
422
+
423
+ async function nodesForOutputs(outRels, set) {
424
+ const ids = [];
425
+ for (const rel of outRels) {
426
+ const id = await nodeForOutput(rel, set);
427
+ if (id && !ids.includes(id)) ids.push(id);
428
+ }
429
+ return ids;
430
+ }
431
+
432
+ /** Delete orphans queued during the pass, unless the path is owned again (case-insensitively). */
433
+ async function flushOrphans() {
434
+ if (orphanQueue.length === 0) return;
435
+ const ownedLower = new Set([...graph.ownerOf.keys()].map((p) => p.toLowerCase()));
436
+ const toDelete = [...new Set(orphanQueue)].filter((p) => !graph.ownerOf.has(p) && !ownedLower.has(p.toLowerCase()));
437
+ orphanQueue = [];
438
+ await deleteOutputs(toDelete);
439
+ }
440
+
441
+ function report(summary) {
442
+ const byKind = (ids) => {
443
+ const counts = new Map();
444
+ for (const id of ids) {
445
+ const k = isLeafId(id) ? id.slice(0, id.indexOf(":")) : parseNodeId(id).kind;
446
+ counts.set(k, (counts.get(k) ?? 0) + 1);
447
+ }
448
+ return [...counts].sort().map(([k, n]) => `${k}×${n}`).join(", ");
449
+ };
450
+ const parts = [];
451
+ if (summary.changedLeaves.length > 0) parts.push(`leaves: ${byKind(summary.changedLeaves)}`);
452
+ if (summary.dirty.size > 0) parts.push(`dirty: ${byKind(summary.dirty)}`);
453
+ parts.push(`changed: ${summary.changedNodes.size ? byKind(summary.changedNodes) : "nothing"}`);
454
+ const restored = summary.computed.size - summary.changedNodes.size;
455
+ if (restored > 0) parts.push(`restored ${restored}`);
456
+ parts.push(`wrote ${summary.written}, deleted ${summary.deleted}`);
457
+ const phases = Object.entries(summary.timings).map(([k, v]) => `${k} ${v}ms`).join(", ");
458
+ log(`⏱ Pass ${summary.elapsed}ms (${phases}) — ${parts.join("; ")}`);
459
+ if (summary.viewedNodes.length > 0) log(` viewed first: ${summary.viewedNodes.join(", ")}`);
460
+ for (const [id, err] of summary.failures) {
461
+ console.error(` ✗ ${id}: ${err.cause?.message ?? err.message}`);
462
+ }
463
+ if (explain) {
464
+ for (const id of summary.changedNodes) {
465
+ const why = graph.reasons.get(id);
466
+ if (why) log(` ↻ ${id} ← ${why}`);
467
+ }
468
+ }
469
+ }
470
+
471
+ return {
472
+ graph,
473
+ env,
474
+ site,
475
+ runPass,
476
+ nodeForOutput: (outRel) => nodeForOutput(outRel, graph.values.get(nodeId("documentSet")) ?? { dirSet: new Set() }),
477
+ /** Node ids owning a page or a soft-closure JSON that a page consumes. */
478
+ candidatesForOutput,
479
+ async close() {
480
+ await terminateParserPool();
481
+ },
482
+ };
483
+ }
484
+
485
+ /** Remove now-empty directories from `dir` up to (not including) `stop`. */
486
+ async function pruneEmptyDirs(dir, stop) {
487
+ while (dir.startsWith(stop + "/")) {
488
+ try {
489
+ const entries = await readdir(dir);
490
+ if (entries.length > 0) return;
491
+ await rmdir(dir);
492
+ } catch {
493
+ return;
494
+ }
495
+ dir = dirname(dir);
496
+ }
497
+ }