@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.
- package/CHANGELOG.md +44 -0
- package/README.md +144 -16
- package/bin/ursa.js +14 -1
- package/meta/templates/default-template/default.css +144 -0
- package/meta/templates/default-template/menu.js +18 -1
- package/meta/templates/default-template/search.js +11 -0
- package/meta/templates/default-template/sectionify.js +17 -9
- package/meta/templates/default-template/widgets.js +4 -0
- package/package.json +1 -2
- package/src/dev.js +13 -23
- package/src/helper/__test__/contentHash.test.js +16 -6
- package/src/helper/__test__/inlineMenu.test.js +142 -0
- package/src/helper/assetBundler.js +93 -19
- package/src/helper/automenu.js +39 -13
- package/src/helper/build/__test__/autoIndex.test.js +2 -132
- package/src/helper/build/__test__/graph.test.js +259 -3
- package/src/helper/build/__test__/pass.test.js +664 -0
- package/src/helper/build/autoIndex.js +6 -371
- package/src/helper/build/excludeFilter.js +1 -2
- package/src/helper/build/footer.js +27 -14
- package/src/helper/build/graph.js +575 -152
- package/src/helper/build/index.js +0 -2
- package/src/helper/build/metadata.js +19 -5
- package/src/helper/build/pass.js +497 -0
- package/src/helper/build/precedence.js +174 -0
- package/src/helper/build/site.js +1392 -0
- package/src/helper/build/templates.js +1 -2
- package/src/helper/build/tracedFs.js +247 -0
- package/src/helper/contentHash.js +0 -78
- package/src/helper/customMenu.js +27 -4
- package/src/helper/fileRenderer.js +119 -111
- package/src/helper/findScriptJs.js +1 -1
- package/src/helper/findStyleCss.js +1 -1
- package/src/helper/folderConfig.js +7 -18
- package/src/helper/fullTextIndex.js +41 -29
- package/src/helper/imageProcessor.js +45 -0
- package/src/helper/inlineMenu.js +275 -0
- package/src/helper/linkValidator.js +118 -127
- package/src/helper/mdxRenderer.js +27 -5
- package/src/helper/menuLabels.js +30 -5
- package/src/helper/whitelistFilter.js +1 -2
- package/src/jobs/generate.js +67 -1829
- package/src/serve.js +317 -697
- package/src/helper/__test__/dependencyTracker.test.js +0 -157
- package/src/helper/build/cacheBust.js +0 -141
- package/src/helper/build/navCache.js +0 -145
- package/src/helper/build/watchCache.js +0 -33
- package/src/helper/dependencyTracker.js +0 -384
|
@@ -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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
+
}
|