@pieceful/ravel-host-node 0.1.1 → 0.3.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/LICENSE +15 -3
- package/README.md +74 -1
- package/package.json +37 -11
- package/src/index.d.ts +38 -4
- package/src/index.js +834 -101
package/src/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { lstat, readFile, readdir, mkdir, rename, unlink, writeFile } from "node:fs/promises";
|
|
2
2
|
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
+
import { deflateRawSync } from "node:zlib";
|
|
3
4
|
import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
4
5
|
import { parse as parseToml } from "smol-toml";
|
|
5
6
|
import {
|
|
@@ -8,10 +9,25 @@ import {
|
|
|
8
9
|
createDeliverableProvenanceMap,
|
|
9
10
|
provenanceMapVersion
|
|
10
11
|
} from "@pieceful/ravel-core";
|
|
11
|
-
import {
|
|
12
|
+
import { asciidocToMap } from "@pieceful/ravel-asciidoc";
|
|
13
|
+
import { htmlToMap } from "@pieceful/ravel-html";
|
|
14
|
+
import { markdownToMap, parseFrontMatter } from "@pieceful/ravel-markdown";
|
|
15
|
+
import { isLitproMarkdown, litproMarkdownToMap } from "@pieceful/ravel-markdown-litpro";
|
|
12
16
|
import { assertRavelMap } from "@pieceful/ravel-map";
|
|
17
|
+
import { mystToMap } from "@pieceful/ravel-myst";
|
|
18
|
+
import { nowebToMap } from "@pieceful/ravel-noweb";
|
|
19
|
+
import { orgToMap } from "@pieceful/ravel-org";
|
|
20
|
+
import { builtinTransforms } from "@pieceful/ravel-transforms";
|
|
21
|
+
import { pathToFileURL } from "node:url";
|
|
13
22
|
|
|
14
23
|
const missing = (error) => error?.code === "ENOENT";
|
|
24
|
+
const componentPattern = /^[a-z][a-z0-9-]*$/;
|
|
25
|
+
|
|
26
|
+
const throwIfAborted = (signal) => {
|
|
27
|
+
if (!signal?.aborted) return;
|
|
28
|
+
if (signal.reason instanceof Error) throw signal.reason;
|
|
29
|
+
throw new DOMException("The Ravel input operation was cancelled.", "AbortError");
|
|
30
|
+
};
|
|
15
31
|
|
|
16
32
|
const inputSource = (uri) => ({
|
|
17
33
|
uri: typeof uri === "string" && uri.length ? uri : "<ravel-input>",
|
|
@@ -37,7 +53,25 @@ const inputError = (code, message, uri) => new RavelInputError([{
|
|
|
37
53
|
source: inputSource(uri)
|
|
38
54
|
}]);
|
|
39
55
|
|
|
40
|
-
const
|
|
56
|
+
const overlayText = (overlays, path) => {
|
|
57
|
+
if (!overlays) return undefined;
|
|
58
|
+
const key = resolve(path);
|
|
59
|
+
const entry = overlays instanceof Map ? overlays.get(key) : overlays[key];
|
|
60
|
+
if (typeof entry === "string") return entry;
|
|
61
|
+
return typeof entry?.text === "string" ? entry.text : undefined;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const overlaysWithText = (overlays, path, text) => {
|
|
65
|
+
const captured = overlays instanceof Map
|
|
66
|
+
? new Map(overlays)
|
|
67
|
+
: new Map(Object.entries(overlays ?? {}).map(([key, value]) => [resolve(key), value]));
|
|
68
|
+
captured.set(resolve(path), { text });
|
|
69
|
+
return captured;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const readInputText = async (path, description, code, overlays) => {
|
|
73
|
+
const overlay = overlayText(overlays, path);
|
|
74
|
+
if (overlay !== undefined) return overlay;
|
|
41
75
|
try {
|
|
42
76
|
return await readFile(path, "utf8");
|
|
43
77
|
} catch (error) {
|
|
@@ -170,8 +204,8 @@ const createOutputScope = async (outputDirectory, rootDirectory) => {
|
|
|
170
204
|
return { root: output, path: (path, description) => scopedPath(output, path, description) };
|
|
171
205
|
};
|
|
172
206
|
|
|
173
|
-
const readMap = async (path) => {
|
|
174
|
-
const text = await readInputText(path, "Ravel Map", "RM201");
|
|
207
|
+
const readMap = async (path, overlays) => {
|
|
208
|
+
const text = await readInputText(path, "Ravel Map", "RM201", overlays);
|
|
175
209
|
let map;
|
|
176
210
|
try {
|
|
177
211
|
map = JSON.parse(text);
|
|
@@ -181,56 +215,238 @@ const readMap = async (path) => {
|
|
|
181
215
|
return assertRavelMap(map, { uri: path });
|
|
182
216
|
};
|
|
183
217
|
|
|
184
|
-
const loadMarkdownFile = async (path, options = {}) =>
|
|
185
|
-
await readInputText(path, "Markdown input", "RM201")
|
|
186
|
-
|
|
187
|
-
)
|
|
218
|
+
const loadMarkdownFile = async (path, options = {}, overlays) => {
|
|
219
|
+
const text = await readInputText(path, "Markdown input", "RM201", overlays);
|
|
220
|
+
if (options.adapter === "markdown-litpro" || options.profile === "litpro" ||
|
|
221
|
+
(options.adapter === undefined && options.profile === undefined && isLitproMarkdown(text))) {
|
|
222
|
+
return litproMarkdownToMap(text, {
|
|
223
|
+
uri: path,
|
|
224
|
+
document: options.document,
|
|
225
|
+
dialect: options.dialect,
|
|
226
|
+
headings: options.headings
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
return markdownToMap(text, {
|
|
230
|
+
uri: path,
|
|
231
|
+
document: options.document,
|
|
232
|
+
mode: options.mode,
|
|
233
|
+
profile: options.profile
|
|
234
|
+
});
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
const loadNowebFile = async (path, options = {}, overlays) => {
|
|
238
|
+
const text = await readInputText(path, "noweb input", "RM201", overlays);
|
|
239
|
+
return nowebToMap(text, {
|
|
240
|
+
uri: path,
|
|
241
|
+
document: options.document,
|
|
242
|
+
dialect: options.dialect,
|
|
243
|
+
references: options.references,
|
|
244
|
+
language: options.language,
|
|
245
|
+
run: options.run,
|
|
246
|
+
provider: options.provider
|
|
247
|
+
});
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
const loadOrgFile = async (path, options = {}, overlays) => {
|
|
251
|
+
const text = await readInputText(path, "Org input", "RM201", overlays);
|
|
252
|
+
return orgToMap(text, {
|
|
253
|
+
uri: path,
|
|
254
|
+
document: options.document,
|
|
255
|
+
references: options.references,
|
|
256
|
+
nowebPipes: options.nowebPipes,
|
|
257
|
+
executionOwner: options.executionOwner,
|
|
258
|
+
run: options.run,
|
|
259
|
+
provider: options.provider
|
|
260
|
+
});
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
const loadMystFile = async (path, options = {}, overlays) => {
|
|
264
|
+
const text = await readInputText(path, "MyST input", "RM201", overlays);
|
|
265
|
+
return mystToMap(text, {
|
|
266
|
+
uri: path,
|
|
267
|
+
document: options.document,
|
|
268
|
+
executionOwner: options.executionOwner,
|
|
269
|
+
run: options.run,
|
|
270
|
+
provider: options.provider
|
|
271
|
+
});
|
|
272
|
+
};
|
|
188
273
|
|
|
189
|
-
const
|
|
274
|
+
const loadAsciiDocFile = async (path, options = {}, overlays) => {
|
|
275
|
+
const text = await readInputText(path, "AsciiDoc input", "RM201", overlays);
|
|
276
|
+
return asciidocToMap(text, {
|
|
277
|
+
uri: path,
|
|
278
|
+
document: options.document,
|
|
279
|
+
run: options.run,
|
|
280
|
+
provider: options.provider
|
|
281
|
+
});
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
const loadHtmlFile = async (path, options = {}, overlays) => {
|
|
285
|
+
const text = await readInputText(path, "HTML input", "RM201", overlays);
|
|
286
|
+
return htmlToMap(text, {
|
|
287
|
+
uri: path,
|
|
288
|
+
document: options.document,
|
|
289
|
+
run: options.run,
|
|
290
|
+
provider: options.provider
|
|
291
|
+
});
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
const collectPretransformMaps = async (
|
|
295
|
+
entryPath,
|
|
296
|
+
entryOptions = {},
|
|
297
|
+
scope,
|
|
298
|
+
overlays = entryOptions.overlays
|
|
299
|
+
) => {
|
|
300
|
+
const signal = entryOptions.signal;
|
|
301
|
+
throwIfAborted(signal);
|
|
190
302
|
const activeScope = scope ?? await createInputScope(dirname(resolve(entryPath)));
|
|
191
303
|
const visited = new Set();
|
|
192
304
|
const maps = [];
|
|
305
|
+
const mapPaths = [];
|
|
193
306
|
const diagnostics = [];
|
|
307
|
+
const authoredSourcePaths = new Set();
|
|
308
|
+
const authoredSourceTexts = new Map();
|
|
309
|
+
let containsJsonMaps = false;
|
|
194
310
|
|
|
195
311
|
const visit = async (path, options = {}) => {
|
|
312
|
+
throwIfAborted(signal);
|
|
196
313
|
const absolutePath = await activeScope.path(path, "Ravel input");
|
|
197
314
|
if (visited.has(absolutePath)) return;
|
|
198
315
|
visited.add(absolutePath);
|
|
199
316
|
|
|
200
317
|
const extension = extname(absolutePath).toLowerCase();
|
|
201
318
|
let map;
|
|
319
|
+
let sourceOverlays = overlays;
|
|
320
|
+
let sourceText;
|
|
321
|
+
const mystExtension = absolutePath.toLowerCase().endsWith(".myst.md");
|
|
202
322
|
if (extension === ".json") {
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
323
|
+
containsJsonMaps = true;
|
|
324
|
+
map = await readMap(absolutePath, overlays);
|
|
325
|
+
} else if (mystExtension || options.adapter === "myst") {
|
|
326
|
+
authoredSourcePaths.add(absolutePath);
|
|
327
|
+
sourceText = await readInputText(absolutePath, "MyST input", "RM201", overlays);
|
|
328
|
+
sourceOverlays = overlaysWithText(overlays, absolutePath, sourceText);
|
|
329
|
+
const result = await loadMystFile(absolutePath, options, sourceOverlays);
|
|
330
|
+
map = result.map;
|
|
331
|
+
diagnostics.push(...result.diagnostics);
|
|
332
|
+
assertRavelMap(map, { uri: absolutePath });
|
|
333
|
+
} else if (extension === ".adoc" || extension === ".asciidoc" ||
|
|
334
|
+
options.adapter === "asciidoc") {
|
|
335
|
+
authoredSourcePaths.add(absolutePath);
|
|
336
|
+
sourceText = await readInputText(absolutePath, "AsciiDoc input", "RM201", overlays);
|
|
337
|
+
sourceOverlays = overlaysWithText(overlays, absolutePath, sourceText);
|
|
338
|
+
const result = await loadAsciiDocFile(absolutePath, options, sourceOverlays);
|
|
339
|
+
map = result.map;
|
|
340
|
+
diagnostics.push(...result.diagnostics);
|
|
341
|
+
assertRavelMap(map, { uri: absolutePath });
|
|
342
|
+
} else if (extension === ".html" || extension === ".htm" ||
|
|
343
|
+
options.adapter === "html") {
|
|
344
|
+
authoredSourcePaths.add(absolutePath);
|
|
345
|
+
sourceText = await readInputText(absolutePath, "HTML input", "RM201", overlays);
|
|
346
|
+
sourceOverlays = overlaysWithText(overlays, absolutePath, sourceText);
|
|
347
|
+
const result = await loadHtmlFile(absolutePath, options, sourceOverlays);
|
|
348
|
+
map = result.map;
|
|
349
|
+
diagnostics.push(...result.diagnostics);
|
|
350
|
+
assertRavelMap(map, { uri: absolutePath });
|
|
351
|
+
} else if (extension === ".org" || options.adapter === "org") {
|
|
352
|
+
authoredSourcePaths.add(absolutePath);
|
|
353
|
+
sourceText = await readInputText(absolutePath, "Org input", "RM201", overlays);
|
|
354
|
+
sourceOverlays = overlaysWithText(overlays, absolutePath, sourceText);
|
|
355
|
+
const result = await loadOrgFile(absolutePath, options, sourceOverlays);
|
|
356
|
+
map = result.map;
|
|
357
|
+
diagnostics.push(...result.diagnostics);
|
|
358
|
+
assertRavelMap(map, { uri: absolutePath });
|
|
359
|
+
} else if (extension === ".nw" || extension === ".noweb" || options.adapter === "noweb") {
|
|
360
|
+
authoredSourcePaths.add(absolutePath);
|
|
361
|
+
sourceText = await readInputText(absolutePath, "noweb input", "RM201", overlays);
|
|
362
|
+
sourceOverlays = overlaysWithText(overlays, absolutePath, sourceText);
|
|
363
|
+
const result = await loadNowebFile(absolutePath, options, sourceOverlays);
|
|
364
|
+
map = result.map;
|
|
365
|
+
diagnostics.push(...result.diagnostics);
|
|
366
|
+
assertRavelMap(map, { uri: absolutePath });
|
|
367
|
+
} else if (extension === ".md" || extension === ".markdown" || extension === ".mdown" || extension === ".qmd") {
|
|
368
|
+
authoredSourcePaths.add(absolutePath);
|
|
369
|
+
sourceText = await readInputText(absolutePath, "Markdown input", "RM201", overlays);
|
|
370
|
+
sourceOverlays = overlaysWithText(overlays, absolutePath, sourceText);
|
|
371
|
+
const result = await loadMarkdownFile(absolutePath, {
|
|
372
|
+
...options,
|
|
373
|
+
profile: options.profile ?? (extension === ".qmd" ? "modern" : undefined)
|
|
374
|
+
}, sourceOverlays);
|
|
206
375
|
map = result.map;
|
|
207
376
|
diagnostics.push(...result.diagnostics);
|
|
208
377
|
assertRavelMap(map, { uri: absolutePath });
|
|
209
378
|
} else {
|
|
210
|
-
throw inputError("RH101", "Ravel input must be a .json map or
|
|
379
|
+
throw inputError("RH101", "Ravel input must be a .json map or supported markup source.", absolutePath);
|
|
211
380
|
}
|
|
381
|
+
if (sourceText !== undefined) authoredSourceTexts.set(absolutePath, sourceText);
|
|
382
|
+
throwIfAborted(signal);
|
|
212
383
|
for (const directive of map.directives ?? []) {
|
|
213
384
|
if (directive.kind !== "in") continue;
|
|
214
385
|
const target = directive.target ?? directive.name;
|
|
215
386
|
if (typeof target !== "string" || !target) {
|
|
216
387
|
throw inputError("RH102", "in directive requires a target path.", directive.source?.uri ?? absolutePath);
|
|
217
388
|
}
|
|
218
|
-
|
|
389
|
+
const adapter = directive.metadata?.adapter;
|
|
390
|
+
const alias = directive.metadata?.legacy?.alias;
|
|
391
|
+
await visit(resolve(dirname(absolutePath), target), {
|
|
392
|
+
...(adapter ? { adapter } : {}),
|
|
393
|
+
...(directive.metadata?.dialect ? { dialect: directive.metadata.dialect } : {}),
|
|
394
|
+
...(typeof alias === "string" && alias ? { document: alias } : {})
|
|
395
|
+
});
|
|
219
396
|
}
|
|
220
397
|
maps.push(map);
|
|
398
|
+
mapPaths.push(absolutePath);
|
|
221
399
|
};
|
|
222
400
|
|
|
223
401
|
await visit(entryPath, entryOptions);
|
|
224
|
-
|
|
402
|
+
throwIfAborted(signal);
|
|
403
|
+
return {
|
|
404
|
+
maps,
|
|
405
|
+
mapPaths,
|
|
406
|
+
diagnostics,
|
|
407
|
+
rootDirectory: activeScope.root,
|
|
408
|
+
authoredSourcePaths: [...authoredSourcePaths].sort(),
|
|
409
|
+
authoredSourceTexts,
|
|
410
|
+
loadedInputPaths: [...visited].sort(),
|
|
411
|
+
containsJsonMaps
|
|
412
|
+
};
|
|
225
413
|
};
|
|
226
414
|
|
|
227
|
-
|
|
228
|
-
const collected = await collectPretransformMaps(resolve(entryPath), options);
|
|
415
|
+
const graphFromCollectedMaps = (collected) => {
|
|
229
416
|
const graph = combineMaps(collected.maps);
|
|
230
417
|
graph.diagnostics.push(...collected.diagnostics);
|
|
231
418
|
return relativizeSourceUris(graph, collected.rootDirectory);
|
|
232
419
|
};
|
|
233
420
|
|
|
421
|
+
const authoredSourceUris = (collected) => collected.authoredSourcePaths
|
|
422
|
+
.map((path) => relative(collected.rootDirectory, path).split(sep).join("/"))
|
|
423
|
+
.sort();
|
|
424
|
+
|
|
425
|
+
const loadedInputUris = (collected) => collected.loadedInputPaths
|
|
426
|
+
.map((path) => relative(collected.rootDirectory, path).split(sep).join("/"))
|
|
427
|
+
.sort();
|
|
428
|
+
|
|
429
|
+
const authoredSourceTexts = (collected) => Object.fromEntries(
|
|
430
|
+
[...collected.authoredSourceTexts.entries()]
|
|
431
|
+
.map(([path, text]) => [
|
|
432
|
+
relative(collected.rootDirectory, path).split(sep).join("/"),
|
|
433
|
+
text
|
|
434
|
+
])
|
|
435
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
436
|
+
);
|
|
437
|
+
|
|
438
|
+
export const loadPretransformGraph = async (entryPath, options = {}) => {
|
|
439
|
+
throwIfAborted(options.signal);
|
|
440
|
+
const collected = await collectPretransformMaps(
|
|
441
|
+
resolve(entryPath),
|
|
442
|
+
options,
|
|
443
|
+
undefined,
|
|
444
|
+
options.overlays
|
|
445
|
+
);
|
|
446
|
+
throwIfAborted(options.signal);
|
|
447
|
+
return graphFromCollectedMaps(collected);
|
|
448
|
+
};
|
|
449
|
+
|
|
234
450
|
const configSource = inputSource;
|
|
235
451
|
|
|
236
452
|
const requireConfigString = (value, description, configPath) => {
|
|
@@ -246,49 +462,472 @@ const reportUnknownKeys = (value, allowed, description, configPath) => {
|
|
|
246
462
|
}
|
|
247
463
|
};
|
|
248
464
|
|
|
249
|
-
|
|
465
|
+
// Names core evaluates itself; a project transform cannot replace them.
|
|
466
|
+
const coreTransformNames = new Set([
|
|
467
|
+
"concat", "trim", "normalize-eol", "indent", "dedent", "replace", "quote-reference",
|
|
468
|
+
"jsontext", "emit", "text", "ch", "delay"
|
|
469
|
+
]);
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Load `[[transforms]]` entries: project-local ES modules whose export is a
|
|
473
|
+
* `(value, context) => string` function. These run as ordinary Node code with
|
|
474
|
+
* the host's privileges, so they are configured only in the TOML file, never by
|
|
475
|
+
* a document. Ravel's built-in transforms are always available and a project
|
|
476
|
+
* module may replace one of them (for example to use another Markdown engine).
|
|
477
|
+
*/
|
|
478
|
+
const loadProjectTransforms = async (entries, scope, absoluteConfig) => {
|
|
479
|
+
const transforms = { ...builtinTransforms };
|
|
480
|
+
if (entries === undefined) return transforms;
|
|
481
|
+
if (!Array.isArray(entries)) throw inputError("RC102", "transforms must contain [[transforms]] entries.", absoluteConfig);
|
|
482
|
+
const seen = new Set();
|
|
483
|
+
for (const [index, entry] of entries.entries()) {
|
|
484
|
+
const path = "transforms[" + index + "]";
|
|
485
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) throw inputError("RC102", path + " must be a table.", absoluteConfig);
|
|
486
|
+
reportUnknownKeys(entry, new Set(["name", "module", "export"]), path, absoluteConfig);
|
|
487
|
+
const name = requireConfigString(entry.name, path + ".name", absoluteConfig);
|
|
488
|
+
if (!/^[a-z][a-z0-9-]*$/.test(name)) throw inputError("RC102", path + ".name must be a lowercase transform name such as \"markdown-it\".", absoluteConfig);
|
|
489
|
+
if (coreTransformNames.has(name)) throw inputError("RC102", path + ".name " + JSON.stringify(name) + " is a core command and cannot be replaced.", absoluteConfig);
|
|
490
|
+
if (seen.has(name)) throw inputError("RC102", path + ".name " + JSON.stringify(name) + " is defined more than once.", absoluteConfig);
|
|
491
|
+
seen.add(name);
|
|
492
|
+
const modulePath = await scope.path(requireConfigString(entry.module, path + ".module", absoluteConfig), path + ".module");
|
|
493
|
+
const exportName = entry.export === undefined ? "default" : requireConfigString(entry.export, path + ".export", absoluteConfig);
|
|
494
|
+
let namespace;
|
|
495
|
+
try {
|
|
496
|
+
// The content hash keeps a long-lived host (an editor) from reusing a
|
|
497
|
+
// stale module after the file changes.
|
|
498
|
+
const url = pathToFileURL(modulePath);
|
|
499
|
+
url.search = "ravel=" + contentHash(await readFile(modulePath, "utf8")).slice(0, 16);
|
|
500
|
+
namespace = await import(url.href);
|
|
501
|
+
} catch (error) {
|
|
502
|
+
throw inputError("RC103", path + " could not load " + modulePath + ": " + (error?.message ?? String(error)), absoluteConfig);
|
|
503
|
+
}
|
|
504
|
+
const transform = namespace[exportName];
|
|
505
|
+
if (typeof transform !== "function") {
|
|
506
|
+
throw inputError("RC103", path + " module " + modulePath + " has no function export " + JSON.stringify(exportName) + ".", absoluteConfig);
|
|
507
|
+
}
|
|
508
|
+
transforms[name] = transform;
|
|
509
|
+
}
|
|
510
|
+
return transforms;
|
|
511
|
+
};
|
|
512
|
+
|
|
513
|
+
const escapeRegExp = (value) => value.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
514
|
+
|
|
515
|
+
/** Translate a `/`-separated glob (`*`, `**`, `?`) into an anchored RegExp. */
|
|
516
|
+
const globPattern = (glob) => {
|
|
517
|
+
let pattern = "";
|
|
518
|
+
for (let index = 0; index < glob.length; index += 1) {
|
|
519
|
+
const character = glob[index];
|
|
520
|
+
if (character === "*" && glob[index + 1] === "*") {
|
|
521
|
+
const slash = glob[index + 2] === "/";
|
|
522
|
+
pattern += slash ? "(?:[^/]+/)*" : ".*";
|
|
523
|
+
index += slash ? 2 : 1;
|
|
524
|
+
} else if (character === "*") pattern += "[^/]*";
|
|
525
|
+
else if (character === "?") pattern += "[^/]";
|
|
526
|
+
else pattern += escapeRegExp(character);
|
|
527
|
+
}
|
|
528
|
+
return new RegExp("^" + pattern + "$");
|
|
529
|
+
};
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* List files under `root` matching `glob`, in a deterministic code-point
|
|
533
|
+
* order. Dot-files and dot-directories are skipped; a symbolic link anywhere
|
|
534
|
+
* in the walk is an error, as everywhere else in the Node host.
|
|
535
|
+
*/
|
|
536
|
+
const globFiles = async (root, glob, description) => {
|
|
537
|
+
if (isAbsolute(glob) || glob.split("/").includes("..")) {
|
|
538
|
+
throw new Error(description + " must be a relative glob inside the Ravel root: " + glob);
|
|
539
|
+
}
|
|
540
|
+
const segments = glob.split("/");
|
|
541
|
+
const firstWild = segments.findIndex((segment) => /[*?]/.test(segment));
|
|
542
|
+
const base = firstWild === -1 ? segments.slice(0, -1) : segments.slice(0, firstWild);
|
|
543
|
+
const start = join(root, ...base);
|
|
544
|
+
const matcher = globPattern(glob);
|
|
545
|
+
const matches = [];
|
|
546
|
+
const walk = async (directory) => {
|
|
547
|
+
let entries;
|
|
548
|
+
try {
|
|
549
|
+
entries = await readdir(directory, { withFileTypes: true });
|
|
550
|
+
} catch (error) {
|
|
551
|
+
if (missing(error)) return;
|
|
552
|
+
throw error;
|
|
553
|
+
}
|
|
554
|
+
for (const entry of entries) {
|
|
555
|
+
if (entry.name.startsWith(".")) continue;
|
|
556
|
+
const path = join(directory, entry.name);
|
|
557
|
+
if (entry.isSymbolicLink()) throw new Error(description + " must not traverse a symbolic link: " + path);
|
|
558
|
+
if (entry.isDirectory()) await walk(path);
|
|
559
|
+
else if (entry.isFile() && matcher.test(portableRelative(root, path))) matches.push(path);
|
|
560
|
+
}
|
|
561
|
+
};
|
|
562
|
+
await assertNoSymlinks(root, start, description);
|
|
563
|
+
await walk(start);
|
|
564
|
+
return matches.sort((left, right) => {
|
|
565
|
+
const a = portableRelative(root, left);
|
|
566
|
+
const b = portableRelative(root, right);
|
|
567
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
568
|
+
});
|
|
569
|
+
};
|
|
570
|
+
|
|
571
|
+
const positionAt = (text, offset) => {
|
|
572
|
+
let line = 0;
|
|
573
|
+
let lineStart = 0;
|
|
574
|
+
for (let index = text.indexOf("\n"); index !== -1 && index < offset; index = text.indexOf("\n", index + 1)) {
|
|
575
|
+
line += 1;
|
|
576
|
+
lineStart = index + 1;
|
|
577
|
+
}
|
|
578
|
+
return { line, column: offset - lineStart, offset };
|
|
579
|
+
};
|
|
580
|
+
|
|
581
|
+
const slugify = (value) => String(value).toLowerCase().normalize("NFKD")
|
|
582
|
+
.replace(/[\u0300-\u036f]/g, "").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
583
|
+
|
|
584
|
+
const fillPattern = (pattern, values, description, configPath) => pattern.replace(/\{([a-z][a-z0-9_-]*)\}/gi, (match, key) => {
|
|
585
|
+
const value = values[key];
|
|
586
|
+
if (value === undefined || value === null || typeof value === "object") {
|
|
587
|
+
throw inputError("RC104", description + " uses {" + key + "}, which is not a text value for this item.", configPath);
|
|
588
|
+
}
|
|
589
|
+
return String(value);
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* Expand `[[collections]]`: every file matching `glob` becomes an item
|
|
594
|
+
* document with a `body` chunk (the text after any front matter, mapped to its
|
|
595
|
+
* exact source range), a `data.json` chunk (front matter plus slug, name, path,
|
|
596
|
+
* and output), and a `page` chunk that pipes the body through `pipe` and, when
|
|
597
|
+
* `layout` names a chunk, fills that layout with `template`. Each item gets one
|
|
598
|
+
* `out` directive from the `out` pattern. A `<name>::index.json` chunk lists
|
|
599
|
+
* every item's data, in order, for index pages.
|
|
600
|
+
*/
|
|
601
|
+
const loadCollections = async (entries, scope, absoluteConfig, overlays) => {
|
|
602
|
+
const empty = { maps: [], inputPaths: [], texts: new Map() };
|
|
603
|
+
if (entries === undefined) return empty;
|
|
604
|
+
if (!Array.isArray(entries)) throw inputError("RC102", "collections must contain [[collections]] entries.", absoluteConfig);
|
|
605
|
+
const maps = [];
|
|
606
|
+
const inputPaths = [];
|
|
607
|
+
const texts = new Map();
|
|
608
|
+
const names = new Set();
|
|
609
|
+
const configUri = portableRelative(scope.root, absoluteConfig);
|
|
610
|
+
for (const [index, entry] of entries.entries()) {
|
|
611
|
+
const path = "collections[" + index + "]";
|
|
612
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) throw inputError("RC102", path + " must be a table.", absoluteConfig);
|
|
613
|
+
reportUnknownKeys(entry, new Set(["name", "glob", "out", "layout", "pipe", "references"]), path, absoluteConfig);
|
|
614
|
+
const name = requireConfigString(entry.name, path + ".name", absoluteConfig);
|
|
615
|
+
if (!componentPattern.test(name)) throw inputError("RC102", path + ".name must be a lowercase identifier such as \"pages\".", absoluteConfig);
|
|
616
|
+
if (names.has(name)) throw inputError("RC102", path + ".name " + JSON.stringify(name) + " is used by more than one collection.", absoluteConfig);
|
|
617
|
+
names.add(name);
|
|
618
|
+
const glob = requireConfigString(entry.glob, path + ".glob", absoluteConfig);
|
|
619
|
+
const outPattern = requireConfigString(entry.out, path + ".out", absoluteConfig);
|
|
620
|
+
const layout = entry.layout === undefined ? null : requireConfigString(entry.layout, path + ".layout", absoluteConfig);
|
|
621
|
+
const pipe = entry.pipe === undefined ? "markdown" : entry.pipe;
|
|
622
|
+
if (typeof pipe !== "string") throw inputError("RC102", path + ".pipe must be a pipeline string such as \"markdown\" or \"\".", absoluteConfig);
|
|
623
|
+
if (entry.references !== undefined && typeof entry.references !== "boolean") {
|
|
624
|
+
throw inputError("RC102", path + ".references must be true or false.", absoluteConfig);
|
|
625
|
+
}
|
|
626
|
+
for (const [field, value] of [["layout", layout ?? ""], ["pipe", pipe]]) {
|
|
627
|
+
if (value.includes("`")) throw inputError("RC102", path + "." + field + " must not contain a backtick.", absoluteConfig);
|
|
628
|
+
}
|
|
629
|
+
let files;
|
|
630
|
+
try {
|
|
631
|
+
files = await globFiles(scope.root, glob, path + ".glob");
|
|
632
|
+
} catch (error) {
|
|
633
|
+
throw inputError("RC104", error.message, absoluteConfig);
|
|
634
|
+
}
|
|
635
|
+
const items = [];
|
|
636
|
+
const slugs = new Map();
|
|
637
|
+
for (const file of files) {
|
|
638
|
+
const uri = portableRelative(scope.root, file);
|
|
639
|
+
const text = await readInputText(file, "collection item", "RC101", overlays);
|
|
640
|
+
let front;
|
|
641
|
+
try {
|
|
642
|
+
front = parseFrontMatter(text);
|
|
643
|
+
} catch (error) {
|
|
644
|
+
throw inputError("RC104", "Invalid front matter in " + uri + ": " + (error?.message ?? String(error)), file);
|
|
645
|
+
}
|
|
646
|
+
const data = front.data ?? {};
|
|
647
|
+
if (typeof data !== "object" || Array.isArray(data)) throw inputError("RC104", "Front matter in " + uri + " must be a mapping.", file);
|
|
648
|
+
const stem = basename(uri, extname(uri));
|
|
649
|
+
const slug = slugify(typeof data.slug === "string" ? data.slug : stem);
|
|
650
|
+
if (!slug) throw inputError("RC104", "Could not derive a slug for " + uri + "; add a `slug` to its front matter.", file);
|
|
651
|
+
if (slugs.has(slug)) throw inputError("RC104", uri + " and " + slugs.get(slug) + " both produce the slug " + JSON.stringify(slug) + " in collection " + name + ".", file);
|
|
652
|
+
slugs.set(slug, uri);
|
|
653
|
+
const dir = dirname(uri) === "." ? "" : dirname(uri);
|
|
654
|
+
const values = { ...data, slug, name: stem, dir, path: uri.slice(0, uri.length - extname(uri).length) };
|
|
655
|
+
const output = fillPattern(outPattern, values, path + ".out", absoluteConfig);
|
|
656
|
+
items.push({ file, uri, text, front, data: { ...data, slug, name: stem, dir, path: uri, out: output, collection: name }, output });
|
|
657
|
+
}
|
|
658
|
+
const sourceAt = (uri, text, start, end) => ({ uri, range: { start: positionAt(text, start), end: positionAt(text, end) } });
|
|
659
|
+
const literalSyntax = (enabled) => ({ data: { ravel: { referenceSyntax: { underscore: enabled } } } });
|
|
660
|
+
for (const item of items) {
|
|
661
|
+
const document = name + "-" + item.data.slug;
|
|
662
|
+
if (!componentPattern.test(document)) throw inputError("RC104", "Collection item " + item.uri + " produces the invalid document id " + document + ".", item.file);
|
|
663
|
+
const chunk = (chunkName, type, body, source, metadata) => ({
|
|
664
|
+
id: document + "::" + chunkName + (type ? "." + type : ""),
|
|
665
|
+
identity: { document, chunk: chunkName, minor: null, type },
|
|
666
|
+
name: chunkName,
|
|
667
|
+
body,
|
|
668
|
+
definitionPipeline: [],
|
|
669
|
+
metadata,
|
|
670
|
+
source
|
|
671
|
+
});
|
|
672
|
+
const bodySource = sourceAt(item.uri, item.text, item.front.bodyStart, item.text.length);
|
|
673
|
+
const frontSource = sourceAt(item.uri, item.text, 0, item.front.end);
|
|
674
|
+
const content = "ch('" + document + "::body" + (pipe.trim() ? " | " + pipe.trim() : "") + "')";
|
|
675
|
+
const page = layout
|
|
676
|
+
? "_`" + layout + " | template(ch('" + document + "::data.json'), 'content', " + content + ")`"
|
|
677
|
+
: "_`" + document + "::body" + (pipe.trim() ? " | " + pipe.trim() : "") + "`";
|
|
678
|
+
maps.push({
|
|
679
|
+
version: 1,
|
|
680
|
+
document: { id: document, uri: item.uri, format: "ravel-collection-item-v1" },
|
|
681
|
+
chunks: [
|
|
682
|
+
chunk("body", null, item.text.slice(item.front.bodyStart), bodySource, { language: "markdown", tags: ["collection"], ...literalSyntax(entry.references === true) }),
|
|
683
|
+
chunk("data", "json", JSON.stringify(item.data, null, 2) + "\n", frontSource, { language: "json", tags: ["collection"], ...literalSyntax(false) })
|
|
684
|
+
],
|
|
685
|
+
directives: [
|
|
686
|
+
{ kind: "create", document, name: "page", body: page, source: configSource(absoluteConfig) },
|
|
687
|
+
{ kind: "out", name: item.output, from: document + "::page", source: configSource(absoluteConfig) }
|
|
688
|
+
]
|
|
689
|
+
});
|
|
690
|
+
inputPaths.push(item.file);
|
|
691
|
+
texts.set(item.file, item.text);
|
|
692
|
+
}
|
|
693
|
+
maps.push({
|
|
694
|
+
version: 1,
|
|
695
|
+
document: { id: name, uri: configUri, format: "ravel-collection-index-v1" },
|
|
696
|
+
chunks: [{
|
|
697
|
+
id: name + "::index.json",
|
|
698
|
+
identity: { document: name, chunk: "index", minor: null, type: "json" },
|
|
699
|
+
name: "index",
|
|
700
|
+
body: JSON.stringify(items.map((item) => item.data), null, 2) + "\n",
|
|
701
|
+
definitionPipeline: [],
|
|
702
|
+
metadata: { language: "json", tags: ["collection"], ...literalSyntax(false) },
|
|
703
|
+
source: configSource(absoluteConfig)
|
|
704
|
+
}],
|
|
705
|
+
directives: []
|
|
706
|
+
});
|
|
707
|
+
}
|
|
708
|
+
return { maps, inputPaths, texts };
|
|
709
|
+
};
|
|
710
|
+
|
|
711
|
+
const loadLiveConfiguration = async (
|
|
712
|
+
live,
|
|
713
|
+
scope,
|
|
714
|
+
configPath,
|
|
715
|
+
overlays,
|
|
716
|
+
{ readResources = true } = {}
|
|
717
|
+
) => {
|
|
718
|
+
if (live === undefined) return undefined;
|
|
719
|
+
if (!live || typeof live !== "object" || Array.isArray(live)) {
|
|
720
|
+
throw inputError("RC102", "live must be a [live] table.", configPath);
|
|
721
|
+
}
|
|
722
|
+
reportUnknownKeys(live, new Set(["modules", "resources"]), "live", configPath);
|
|
723
|
+
if (live.modules !== undefined && !Array.isArray(live.modules)) {
|
|
724
|
+
throw inputError("RC102", "live.modules must contain [[live.modules]] entries.", configPath);
|
|
725
|
+
}
|
|
726
|
+
if (live.resources !== undefined && !Array.isArray(live.resources)) {
|
|
727
|
+
throw inputError("RC102", "live.resources must contain [[live.resources]] entries.", configPath);
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
const moduleSpecifiers = new Set();
|
|
731
|
+
const modules = (live.modules ?? []).map((entry, index) => {
|
|
732
|
+
const description = "live.modules[" + index + "]";
|
|
733
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
|
734
|
+
throw inputError("RC102", description + " must be a table.", configPath);
|
|
735
|
+
}
|
|
736
|
+
reportUnknownKeys(entry, new Set(["specifier", "from"]), description, configPath);
|
|
737
|
+
const specifier = requireConfigString(entry.specifier, description + ".specifier", configPath);
|
|
738
|
+
const from = requireConfigString(entry.from, description + ".from", configPath);
|
|
739
|
+
if (moduleSpecifiers.has(specifier)) {
|
|
740
|
+
throw inputError("RC102", "Duplicate live module specifier: " + specifier, configPath);
|
|
741
|
+
}
|
|
742
|
+
moduleSpecifiers.add(specifier);
|
|
743
|
+
return { specifier, from, source: configSource(configPath) };
|
|
744
|
+
});
|
|
745
|
+
|
|
746
|
+
const resourceNames = new Set();
|
|
747
|
+
const resourceResults = await Promise.all((live.resources ?? []).map(async (entry, index) => {
|
|
748
|
+
const description = "live.resources[" + index + "]";
|
|
749
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
|
750
|
+
throw inputError("RC102", description + " must be a table.", configPath);
|
|
751
|
+
}
|
|
752
|
+
reportUnknownKeys(entry, new Set(["name", "path"]), description, configPath);
|
|
753
|
+
const name = requireConfigString(entry.name, description + ".name", configPath);
|
|
754
|
+
const path = requireConfigString(entry.path, description + ".path", configPath);
|
|
755
|
+
if (resourceNames.has(name)) {
|
|
756
|
+
throw inputError("RC102", "Duplicate live resource name: " + name, configPath);
|
|
757
|
+
}
|
|
758
|
+
resourceNames.add(name);
|
|
759
|
+
const absolutePath = await scope.path(path, description + ".path");
|
|
760
|
+
const declaration = { name, path, source: configSource(configPath) };
|
|
761
|
+
if (!readResources) return { declaration };
|
|
762
|
+
return {
|
|
763
|
+
declaration,
|
|
764
|
+
entry: [name, await readInputText(
|
|
765
|
+
absolutePath,
|
|
766
|
+
"live resource",
|
|
767
|
+
"RC103",
|
|
768
|
+
overlays
|
|
769
|
+
)]
|
|
770
|
+
};
|
|
771
|
+
}));
|
|
772
|
+
|
|
773
|
+
return {
|
|
774
|
+
modules,
|
|
775
|
+
resources: Object.fromEntries(resourceResults.flatMap((result) =>
|
|
776
|
+
result.entry ? [result.entry] : []
|
|
777
|
+
)),
|
|
778
|
+
resourceDeclarations: resourceResults.map(({ declaration }) => declaration)
|
|
779
|
+
};
|
|
780
|
+
};
|
|
781
|
+
|
|
782
|
+
export const loadTomlBuild = async (configPath, options = {}) => {
|
|
783
|
+
throwIfAborted(options.signal);
|
|
250
784
|
const absoluteConfig = resolve(configPath);
|
|
251
785
|
const scope = await createInputScope(dirname(absoluteConfig));
|
|
252
786
|
const configFile = await scope.path(absoluteConfig, "Ravel TOML config");
|
|
253
787
|
let config;
|
|
254
788
|
try {
|
|
255
|
-
config = parseToml(await readInputText(
|
|
789
|
+
config = parseToml(await readInputText(
|
|
790
|
+
configFile,
|
|
791
|
+
"Ravel TOML config",
|
|
792
|
+
"RC101",
|
|
793
|
+
options.overlays
|
|
794
|
+
));
|
|
795
|
+
throwIfAborted(options.signal);
|
|
256
796
|
} catch (error) {
|
|
257
797
|
if (Array.isArray(error?.diagnostics)) throw error;
|
|
258
798
|
throw inputError("RC101", "Invalid Ravel TOML config: " + (error?.message ?? String(error)), absoluteConfig);
|
|
259
799
|
}
|
|
260
|
-
reportUnknownKeys(config, new Set(["version", "files", "build", "outputs"]), "config", absoluteConfig);
|
|
800
|
+
reportUnknownKeys(config, new Set(["version", "files", "collections", "build", "outputs", "live", "transforms"]), "config", absoluteConfig);
|
|
261
801
|
if (config.version !== 1) throw inputError("RC102", "version must be 1.", absoluteConfig);
|
|
262
|
-
if (!Array.isArray(config.files)
|
|
263
|
-
throw inputError("RC102", "files must contain
|
|
802
|
+
if (config.files !== undefined && !Array.isArray(config.files)) {
|
|
803
|
+
throw inputError("RC102", "files must contain [[files]] entries.", absoluteConfig);
|
|
804
|
+
}
|
|
805
|
+
if (!config.files?.length && !config.collections?.length) {
|
|
806
|
+
throw inputError("RC102", "files must contain one or more [[files]] entries, or the config must define [[collections]].", absoluteConfig);
|
|
264
807
|
}
|
|
265
|
-
if (!config.build || typeof config.build !== "object") {
|
|
808
|
+
if (config.build !== undefined && (!config.build || typeof config.build !== "object" || Array.isArray(config.build))) {
|
|
266
809
|
throw inputError("RC102", "build must be a [build] table.", absoluteConfig);
|
|
267
810
|
}
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
if (
|
|
811
|
+
const build = config.build ?? {};
|
|
812
|
+
reportUnknownKeys(build, new Set(["name", "out_dir", "clean", "backup"]), "build", absoluteConfig);
|
|
813
|
+
if (build.name !== undefined) requireConfigString(build.name, "build.name", absoluteConfig);
|
|
814
|
+
if (build.clean !== undefined && typeof build.clean !== "boolean") {
|
|
271
815
|
throw inputError("RC102", "build.clean must be true or false.", absoluteConfig);
|
|
272
816
|
}
|
|
273
|
-
if (
|
|
274
|
-
(typeof
|
|
817
|
+
if (build.backup !== undefined && typeof build.backup !== "boolean" &&
|
|
818
|
+
(typeof build.backup !== "string" || build.backup.length === 0)) {
|
|
275
819
|
throw inputError("RC102", "build.backup must be true, false, or a non-empty .zip path.", absoluteConfig);
|
|
276
820
|
}
|
|
277
|
-
if (typeof
|
|
821
|
+
if (typeof build.backup === "string" && extname(build.backup).toLowerCase() !== ".zip") {
|
|
278
822
|
throw inputError("RC102", "build.backup must name a .zip file.", absoluteConfig);
|
|
279
823
|
}
|
|
280
|
-
const outDirectory =
|
|
824
|
+
const outDirectory = build.out_dir === undefined
|
|
825
|
+
? undefined
|
|
826
|
+
: requireConfigString(build.out_dir, "build.out_dir", absoluteConfig);
|
|
281
827
|
const baseDirectory = scope.root;
|
|
282
|
-
const
|
|
828
|
+
const live = await loadLiveConfiguration(
|
|
829
|
+
config.live,
|
|
830
|
+
scope,
|
|
831
|
+
absoluteConfig,
|
|
832
|
+
options.overlays,
|
|
833
|
+
{ readResources: options.readLiveResources !== false }
|
|
834
|
+
);
|
|
835
|
+
const results = await Promise.all((config.files ?? []).map(async (file, index) => {
|
|
283
836
|
if (!file || typeof file !== "object") throw inputError("RC102", "files[" + index + "] must be a table.", absoluteConfig);
|
|
284
|
-
reportUnknownKeys(file, new Set([
|
|
837
|
+
reportUnknownKeys(file, new Set([
|
|
838
|
+
"path", "document", "mode", "profile", "adapter", "dialect",
|
|
839
|
+
"references", "language", "noweb_pipes", "execution_owner", "run", "provider"
|
|
840
|
+
]), "files[" + index + "]", absoluteConfig);
|
|
285
841
|
const path = requireConfigString(file.path, "files[" + index + "].path", absoluteConfig);
|
|
286
842
|
if (file.document !== undefined) requireConfigString(file.document, "files[" + index + "].document", absoluteConfig);
|
|
287
843
|
const mode = file.mode ?? "opt-in";
|
|
288
844
|
if (!["opt-in", "primary"].includes(mode)) throw inputError("RC102", "files[" + index + "].mode must be opt-in or primary.", absoluteConfig);
|
|
289
|
-
|
|
845
|
+
const profile = file.profile;
|
|
846
|
+
if (profile !== undefined && !["fences", "modern", "litpro"].includes(profile)) {
|
|
847
|
+
throw inputError("RC102", "files[" + index + "].profile must be fences, modern, or litpro.", absoluteConfig);
|
|
848
|
+
}
|
|
849
|
+
const adapter = file.adapter;
|
|
850
|
+
if (adapter !== undefined && !["asciidoc", "html", "markdown", "markdown-litpro", "myst", "noweb", "org"].includes(adapter)) {
|
|
851
|
+
throw inputError("RC102", "files[" + index + "].adapter must be asciidoc, html, markdown, markdown-litpro, myst, noweb, or org.", absoluteConfig);
|
|
852
|
+
}
|
|
853
|
+
const dialect = file.dialect;
|
|
854
|
+
const supportedDialects = adapter === "noweb"
|
|
855
|
+
? ["noweb", "noweb-plus"]
|
|
856
|
+
: adapter === "asciidoc" || adapter === "html" ||
|
|
857
|
+
adapter === "org" || adapter === "myst"
|
|
858
|
+
? []
|
|
859
|
+
: ["litpro-2017", "pieceful-2020", "litpro-plus"];
|
|
860
|
+
if (dialect !== undefined && !supportedDialects.includes(dialect)) {
|
|
861
|
+
throw inputError("RC102", "files[" + index + "].dialect is not supported by its adapter.", absoluteConfig);
|
|
862
|
+
}
|
|
863
|
+
const references = file.references;
|
|
864
|
+
const supportedReferences = adapter === "org"
|
|
865
|
+
? ["org-noweb", "underscore-quote", "both"]
|
|
866
|
+
: ["noweb", "underscore-quote", "both"];
|
|
867
|
+
if (references !== undefined && !supportedReferences.includes(references)) {
|
|
868
|
+
throw inputError("RC102", "files[" + index + "].references is not supported by its adapter.", absoluteConfig);
|
|
869
|
+
}
|
|
870
|
+
if (file.language !== undefined) requireConfigString(file.language, "files[" + index + "].language", absoluteConfig);
|
|
871
|
+
if (file.noweb_pipes !== undefined && typeof file.noweb_pipes !== "boolean") {
|
|
872
|
+
throw inputError("RC102", "files[" + index + "].noweb_pipes must be true or false.", absoluteConfig);
|
|
873
|
+
}
|
|
874
|
+
const executionOwner = file.execution_owner;
|
|
875
|
+
const supportedExecutionOwners = adapter === "myst"
|
|
876
|
+
? ["myst", "ravel"]
|
|
877
|
+
: ["org", "ravel"];
|
|
878
|
+
if (executionOwner !== undefined && !supportedExecutionOwners.includes(executionOwner)) {
|
|
879
|
+
throw inputError(
|
|
880
|
+
"RC102",
|
|
881
|
+
"files[" + index + "].execution_owner is not supported by its adapter.",
|
|
882
|
+
absoluteConfig
|
|
883
|
+
);
|
|
884
|
+
}
|
|
885
|
+
if (file.run !== undefined && typeof file.run !== "boolean") {
|
|
886
|
+
throw inputError("RC102", "files[" + index + "].run must be true or false.", absoluteConfig);
|
|
887
|
+
}
|
|
888
|
+
if (file.provider !== undefined) requireConfigString(file.provider, "files[" + index + "].provider", absoluteConfig);
|
|
889
|
+
if (file.language !== undefined && adapter !== "noweb") {
|
|
890
|
+
throw inputError("RC102", "files[" + index + "].language currently requires adapter = \"noweb\".", absoluteConfig);
|
|
891
|
+
}
|
|
892
|
+
if (file.noweb_pipes !== undefined && adapter !== "org") {
|
|
893
|
+
throw inputError("RC102", "noweb_pipes requires adapter = \"org\".", absoluteConfig);
|
|
894
|
+
}
|
|
895
|
+
if (executionOwner !== undefined && !["org", "myst"].includes(adapter)) {
|
|
896
|
+
throw inputError("RC102", "execution_owner requires adapter = \"org\" or \"myst\".", absoluteConfig);
|
|
897
|
+
}
|
|
898
|
+
if (references !== undefined && !["noweb", "org"].includes(adapter)) {
|
|
899
|
+
throw inputError("RC102", "references requires adapter = \"noweb\" or \"org\".", absoluteConfig);
|
|
900
|
+
}
|
|
901
|
+
if ((file.run !== undefined || file.provider !== undefined) &&
|
|
902
|
+
!["asciidoc", "html", "noweb", "org", "myst"].includes(adapter)) {
|
|
903
|
+
throw inputError("RC102", "run and provider settings require adapter = \"asciidoc\", \"html\", \"noweb\", \"org\", or \"myst\".", absoluteConfig);
|
|
904
|
+
}
|
|
905
|
+
return collectPretransformMaps(resolve(baseDirectory, path), {
|
|
906
|
+
document: file.document,
|
|
907
|
+
mode,
|
|
908
|
+
profile,
|
|
909
|
+
adapter,
|
|
910
|
+
dialect,
|
|
911
|
+
references,
|
|
912
|
+
language: file.language,
|
|
913
|
+
nowebPipes: file.noweb_pipes,
|
|
914
|
+
executionOwner,
|
|
915
|
+
run: file.run,
|
|
916
|
+
provider: file.provider,
|
|
917
|
+
signal: options.signal
|
|
918
|
+
}, scope, options.overlays);
|
|
290
919
|
}));
|
|
291
|
-
|
|
920
|
+
throwIfAborted(options.signal);
|
|
921
|
+
// Several [[files]] may import the same document; load it once, keeping the
|
|
922
|
+
// first occurrence in configuration order.
|
|
923
|
+
const seenMapPaths = new Set();
|
|
924
|
+
const collections = await loadCollections(config.collections, scope, absoluteConfig, options.overlays);
|
|
925
|
+
const pretransform = combineMaps([...results.flatMap((result) => result.maps.filter((_, index) => {
|
|
926
|
+
const path = result.mapPaths[index];
|
|
927
|
+
if (seenMapPaths.has(path)) return false;
|
|
928
|
+
seenMapPaths.add(path);
|
|
929
|
+
return true;
|
|
930
|
+
})), ...collections.maps]);
|
|
292
931
|
pretransform.diagnostics.push(...results.flatMap((result) => result.diagnostics));
|
|
293
932
|
for (const output of config.outputs ?? []) {
|
|
294
933
|
if (!output || typeof output !== "object") throw inputError("RC102", "Each [[outputs]] entry must be a table.", absoluteConfig);
|
|
@@ -302,35 +941,58 @@ export const loadTomlBuild = async (configPath) => {
|
|
|
302
941
|
}
|
|
303
942
|
return {
|
|
304
943
|
pretransform: relativizeSourceUris(pretransform, scope.root),
|
|
305
|
-
|
|
944
|
+
authoredSourceUris: [...new Set([
|
|
945
|
+
...results.flatMap(authoredSourceUris),
|
|
946
|
+
...collections.inputPaths.map((path) => portableRelative(scope.root, path))
|
|
947
|
+
])].sort(),
|
|
948
|
+
authoredSourceTexts: Object.assign({}, ...results.map(authoredSourceTexts), Object.fromEntries(
|
|
949
|
+
[...collections.texts.entries()].map(([path, text]) => [portableRelative(scope.root, path), text])
|
|
950
|
+
)),
|
|
951
|
+
loadedInputUris: [...new Set([
|
|
952
|
+
relative(scope.root, absoluteConfig).split(sep).join("/"),
|
|
953
|
+
...results.flatMap(loadedInputUris),
|
|
954
|
+
...collections.inputPaths.map((path) => portableRelative(scope.root, path))
|
|
955
|
+
])].sort(),
|
|
956
|
+
sourceEditsAllowed: results.every((result) => !result.containsJsonMaps),
|
|
957
|
+
...(outDirectory === undefined ? {} : { outputDirectory: await scope.path(outDirectory, "build.out_dir") }),
|
|
306
958
|
rootDirectory: scope.root,
|
|
307
959
|
buildOptions: {
|
|
308
|
-
clean:
|
|
309
|
-
backup:
|
|
310
|
-
}
|
|
960
|
+
clean: build.clean === true,
|
|
961
|
+
backup: build.backup ?? false
|
|
962
|
+
},
|
|
963
|
+
transforms: await loadProjectTransforms(config.transforms, scope, absoluteConfig),
|
|
964
|
+
...(live ? { live } : {})
|
|
311
965
|
};
|
|
312
966
|
};
|
|
313
967
|
|
|
314
|
-
/** Load a JSON map, one
|
|
968
|
+
/** Load a JSON map, one markup source document, or a Ravel TOML build run. */
|
|
315
969
|
export const loadBuildInput = async (inputPath, options = {}) => {
|
|
970
|
+
throwIfAborted(options.signal);
|
|
316
971
|
const extension = extname(inputPath).toLowerCase();
|
|
317
|
-
if (extension === ".toml") return loadTomlBuild(inputPath);
|
|
318
|
-
if (extension === ".md" || extension === ".markdown" || extension === ".mdown"
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
972
|
+
if (extension === ".toml") return loadTomlBuild(inputPath, options);
|
|
973
|
+
if (extension === ".md" || extension === ".markdown" || extension === ".mdown" || extension === ".qmd" ||
|
|
974
|
+
extension === ".nw" || extension === ".noweb" || extension === ".org" ||
|
|
975
|
+
extension === ".adoc" || extension === ".asciidoc" ||
|
|
976
|
+
extension === ".html" || extension === ".htm" || extension === ".json") {
|
|
977
|
+
const collected = await collectPretransformMaps(
|
|
978
|
+
resolve(inputPath),
|
|
979
|
+
options,
|
|
980
|
+
undefined,
|
|
981
|
+
options.overlays
|
|
982
|
+
);
|
|
983
|
+
throwIfAborted(options.signal);
|
|
327
984
|
return {
|
|
328
|
-
pretransform:
|
|
985
|
+
pretransform: graphFromCollectedMaps(collected),
|
|
329
986
|
outputDirectory: undefined,
|
|
330
|
-
rootDirectory:
|
|
987
|
+
rootDirectory: collected.rootDirectory,
|
|
988
|
+
authoredSourceUris: authoredSourceUris(collected),
|
|
989
|
+
authoredSourceTexts: authoredSourceTexts(collected),
|
|
990
|
+
loadedInputUris: loadedInputUris(collected),
|
|
991
|
+
sourceEditsAllowed: !collected.containsJsonMaps,
|
|
992
|
+
transforms: { ...builtinTransforms }
|
|
331
993
|
};
|
|
332
994
|
}
|
|
333
|
-
throw inputError("RH101", "Ravel input must be a .json map,
|
|
995
|
+
throw inputError("RH101", "Ravel input must be a .json map, supported markup document, or .toml build config.", inputPath);
|
|
334
996
|
};
|
|
335
997
|
|
|
336
998
|
const safeDestination = (outputDirectory, name) => {
|
|
@@ -359,19 +1021,21 @@ const sidecarName = (name) => name + ".ravelmap";
|
|
|
359
1021
|
/** Build a deterministic, non-writing description of artifact output. */
|
|
360
1022
|
export const planDeliverables = (program, outputDirectory) => {
|
|
361
1023
|
const root = resolve(outputDirectory);
|
|
362
|
-
|
|
1024
|
+
// Compare case-insensitively so a plan is portable to macOS and Windows,
|
|
1025
|
+
// where `README.md` and `readme.md` name the same file.
|
|
1026
|
+
const destinations = new Set([manifestName, textManifestName].map((name) => safeDestination(root, name).toLowerCase()));
|
|
1027
|
+
const claim = (destination, message) => {
|
|
1028
|
+
const key = destination.toLowerCase();
|
|
1029
|
+
if (destinations.has(key)) throw new Error(message);
|
|
1030
|
+
destinations.add(key);
|
|
1031
|
+
};
|
|
363
1032
|
const deliverables = orderedDeliverables(program).map((deliverable) => {
|
|
364
1033
|
const destination = safeDestination(root, deliverable.name);
|
|
365
|
-
|
|
366
|
-
destinations.add(destination);
|
|
1034
|
+
claim(destination, "Deliverable collides with another output or Ravel's manifest (names are compared case-insensitively): " + deliverable.name);
|
|
367
1035
|
const hasProvenance = Array.isArray(deliverable.segments);
|
|
368
1036
|
const ravelmap = hasProvenance ? sidecarName(deliverable.name) : null;
|
|
369
1037
|
if (ravelmap) {
|
|
370
|
-
|
|
371
|
-
if (destinations.has(mapDestination)) {
|
|
372
|
-
throw new Error("A deliverable provenance map collides with another output: " + ravelmap);
|
|
373
|
-
}
|
|
374
|
-
destinations.add(mapDestination);
|
|
1038
|
+
claim(safeDestination(root, ravelmap), "A deliverable provenance map collides with another output: " + ravelmap);
|
|
375
1039
|
}
|
|
376
1040
|
return {
|
|
377
1041
|
name: deliverable.name,
|
|
@@ -383,10 +1047,7 @@ export const planDeliverables = (program, outputDirectory) => {
|
|
|
383
1047
|
};
|
|
384
1048
|
});
|
|
385
1049
|
if (deliverables.some((deliverable) => deliverable.ravelmap)) {
|
|
386
|
-
|
|
387
|
-
if (destinations.has(aggregateDestination)) {
|
|
388
|
-
throw new Error("The aggregate provenance map collides with another output: " + aggregateProvenanceName);
|
|
389
|
-
}
|
|
1050
|
+
claim(safeDestination(root, aggregateProvenanceName), "The aggregate provenance map collides with another output: " + aggregateProvenanceName);
|
|
390
1051
|
}
|
|
391
1052
|
return {
|
|
392
1053
|
version: 1,
|
|
@@ -498,8 +1159,9 @@ const writeFilesAtomically = async (scope, entries, description) => {
|
|
|
498
1159
|
const token = randomUUID();
|
|
499
1160
|
const temporary = join(dirname(entry.destination), "." + basename(entry.destination) + ".ravel-stage-" + token);
|
|
500
1161
|
const backup = join(dirname(entry.destination), "." + basename(entry.destination) + ".ravel-backup-" + token);
|
|
501
|
-
|
|
1162
|
+
// Record the entry before writing so a failed write still removes its temporary file.
|
|
502
1163
|
staged.push({ ...entry, exists, temporary, backup, committed: false, backedUp: false });
|
|
1164
|
+
await writeFile(temporary, entry.value, "utf8");
|
|
503
1165
|
}
|
|
504
1166
|
|
|
505
1167
|
for (const entry of staged) {
|
|
@@ -522,16 +1184,25 @@ const writeFilesAtomically = async (scope, entries, description) => {
|
|
|
522
1184
|
}
|
|
523
1185
|
}
|
|
524
1186
|
} catch (error) {
|
|
1187
|
+
// Restore every entry even if one restoration fails, then report the
|
|
1188
|
+
// original failure with any rollback problems attached.
|
|
1189
|
+
const rollbackErrors = [];
|
|
525
1190
|
for (const entry of staged.slice().reverse()) {
|
|
526
|
-
|
|
527
|
-
|
|
1191
|
+
for (const step of [
|
|
1192
|
+
() => entry.committed && removeIfPresent(entry.destination),
|
|
1193
|
+
() => entry.backedUp && rename(entry.backup, entry.destination),
|
|
1194
|
+
() => removeIfPresent(entry.temporary)
|
|
1195
|
+
]) {
|
|
528
1196
|
try {
|
|
529
|
-
await
|
|
1197
|
+
await step();
|
|
530
1198
|
} catch (rollbackError) {
|
|
531
|
-
if (!missing(rollbackError))
|
|
1199
|
+
if (!missing(rollbackError)) rollbackErrors.push(rollbackError);
|
|
532
1200
|
}
|
|
533
1201
|
}
|
|
534
|
-
|
|
1202
|
+
}
|
|
1203
|
+
if (rollbackErrors.length) {
|
|
1204
|
+
error.message += " (rollback incomplete: " + rollbackErrors.map((entry) => entry.message).join("; ") + ")";
|
|
1205
|
+
error.rollbackErrors = rollbackErrors;
|
|
535
1206
|
}
|
|
536
1207
|
throw error;
|
|
537
1208
|
}
|
|
@@ -622,10 +1293,14 @@ export const writeBuildManifest = async (program, outputDirectory, {
|
|
|
622
1293
|
export const writeBuildArtifacts = async (program, outputDirectory, {
|
|
623
1294
|
rootDirectory = outputDirectory,
|
|
624
1295
|
stale = [],
|
|
1296
|
+
clean = false,
|
|
625
1297
|
generatedAt = new Date().toISOString()
|
|
626
1298
|
} = {}) => {
|
|
627
1299
|
const scope = await createOutputScope(outputDirectory, rootDirectory);
|
|
628
1300
|
const plan = planDeliverables(program, scope.root);
|
|
1301
|
+
// With `clean`, previously managed files are removed only after the new
|
|
1302
|
+
// build has committed, so a failed write keeps the last good output.
|
|
1303
|
+
const previous = clean ? await readManifest(scope.root, rootDirectory) : null;
|
|
629
1304
|
const manifest = createBuildManifest(program, scope.root, { stale, builtAt: generatedAt });
|
|
630
1305
|
const deliverableEntries = plan.deliverables.map((deliverable) => ({
|
|
631
1306
|
destination: safeDestination(scope.root, deliverable.name),
|
|
@@ -654,8 +1329,27 @@ export const writeBuildArtifacts = async (program, outputDirectory, {
|
|
|
654
1329
|
{ destination: manifestDestination, value: JSON.stringify(manifest, null, 2) + "\n" },
|
|
655
1330
|
{ destination: textManifestDestination, value: formatTextManifest(manifest, generatedAt) }
|
|
656
1331
|
], "Build artifact path");
|
|
1332
|
+
// Remove previously managed deliverables (and their sidecars) that this
|
|
1333
|
+
// build no longer produces; files it rewrote were already replaced.
|
|
1334
|
+
const active = new Set(plan.deliverables.map((deliverable) => deliverable.name));
|
|
1335
|
+
const supersededDeliverables = previous
|
|
1336
|
+
? managedDeliverables(previous.manifest).filter((deliverable) => !active.has(deliverable.name))
|
|
1337
|
+
: [];
|
|
1338
|
+
const superseded = supersededDeliverables.flatMap((deliverable) => [
|
|
1339
|
+
safeDestination(scope.root, deliverable.name),
|
|
1340
|
+
...(deliverable.ravelmap ? [safeDestination(scope.root, deliverable.ravelmap)] : [])
|
|
1341
|
+
]);
|
|
1342
|
+
if (previous?.manifest.provenance?.aggregate && !aggregateEntry.length) {
|
|
1343
|
+
superseded.push(safeDestination(scope.root, previous.manifest.provenance.aggregate));
|
|
1344
|
+
}
|
|
1345
|
+
await removeManagedTargets(scope, superseded);
|
|
657
1346
|
return {
|
|
658
1347
|
written: deliverableEntries.map((entry) => entry.destination),
|
|
1348
|
+
removed: supersededDeliverables.map((deliverable) => ({
|
|
1349
|
+
name: deliverable.name,
|
|
1350
|
+
path: portableRelative(scope.root, safeDestination(scope.root, deliverable.name)),
|
|
1351
|
+
from: deliverable.from ?? null
|
|
1352
|
+
})).sort((left, right) => left.name.localeCompare(right.name)),
|
|
659
1353
|
provenance: {
|
|
660
1354
|
sidecars: provenanceEntries.map((entry) => entry.destination),
|
|
661
1355
|
aggregate: aggregateEntry[0]?.destination ?? null
|
|
@@ -687,6 +1381,23 @@ const planManagedRemoval = async (outputDirectory, rootDirectory, staleOnly) =>
|
|
|
687
1381
|
};
|
|
688
1382
|
};
|
|
689
1383
|
|
|
1384
|
+
/** Delete managed files only after every path component passes the symlink policy. */
|
|
1385
|
+
const removeManagedTargets = async (scope, targets) => {
|
|
1386
|
+
for (const target of targets) {
|
|
1387
|
+
await assertNoSymlinks(scope.root, target, "Managed output path");
|
|
1388
|
+
await existingFile(target, "Managed output path");
|
|
1389
|
+
}
|
|
1390
|
+
for (const target of targets) await removeIfPresent(target);
|
|
1391
|
+
};
|
|
1392
|
+
|
|
1393
|
+
const managedTargets = (root, manifest) => [
|
|
1394
|
+
...managedDeliverables(manifest).flatMap((deliverable) => [
|
|
1395
|
+
safeDestination(root, deliverable.name),
|
|
1396
|
+
...(deliverable.ravelmap ? [safeDestination(root, deliverable.ravelmap)] : [])
|
|
1397
|
+
]),
|
|
1398
|
+
...(manifest.provenance?.aggregate ? [safeDestination(root, manifest.provenance.aggregate)] : [])
|
|
1399
|
+
];
|
|
1400
|
+
|
|
690
1401
|
const removeManagedFiles = async (plan, rootDirectory, dryRun, removeManifest) => {
|
|
691
1402
|
if (!plan.manifest || dryRun) return { removed: plan.deliverables, manifestRemoved: false };
|
|
692
1403
|
const scope = await createOutputScope(plan.outputDirectory, rootDirectory);
|
|
@@ -698,8 +1409,7 @@ const removeManagedFiles = async (plan, rootDirectory, dryRun, removeManifest) =
|
|
|
698
1409
|
...(plan.aggregate ? [safeDestination(scope.root, plan.aggregate)] : []),
|
|
699
1410
|
...(removeManifest ? [safeDestination(scope.root, manifestName), safeDestination(scope.root, textManifestName)] : [])
|
|
700
1411
|
];
|
|
701
|
-
|
|
702
|
-
for (const target of targets) await removeIfPresent(target);
|
|
1412
|
+
await removeManagedTargets(scope, targets);
|
|
703
1413
|
return { removed: plan.deliverables, manifestRemoved: removeManifest };
|
|
704
1414
|
};
|
|
705
1415
|
|
|
@@ -765,30 +1475,34 @@ const zipArchive = (files) => {
|
|
|
765
1475
|
}
|
|
766
1476
|
const { date, time } = zipDateTime(file.mtime);
|
|
767
1477
|
const crc = crc32(file.value);
|
|
1478
|
+
// Deflate when it helps; tiny or incompressible files stay stored.
|
|
1479
|
+
const deflated = deflateRawSync(file.value);
|
|
1480
|
+
const method = deflated.length < size ? 8 : 0;
|
|
1481
|
+
const data = method === 8 ? deflated : file.value;
|
|
768
1482
|
const local = Buffer.alloc(30);
|
|
769
1483
|
local.writeUInt32LE(0x04034b50, 0);
|
|
770
1484
|
local.writeUInt16LE(20, 4);
|
|
771
1485
|
local.writeUInt16LE(0x0800, 6);
|
|
772
|
-
local.writeUInt16LE(
|
|
1486
|
+
local.writeUInt16LE(method, 8);
|
|
773
1487
|
local.writeUInt16LE(time, 10);
|
|
774
1488
|
local.writeUInt16LE(date, 12);
|
|
775
1489
|
local.writeUInt32LE(crc, 14);
|
|
776
|
-
local.writeUInt32LE(
|
|
1490
|
+
local.writeUInt32LE(data.length, 18);
|
|
777
1491
|
local.writeUInt32LE(size, 22);
|
|
778
1492
|
local.writeUInt16LE(name.length, 26);
|
|
779
1493
|
local.writeUInt16LE(0, 28);
|
|
780
|
-
locals.push(local, name,
|
|
1494
|
+
locals.push(local, name, data);
|
|
781
1495
|
|
|
782
1496
|
const directory = Buffer.alloc(46);
|
|
783
1497
|
directory.writeUInt32LE(0x02014b50, 0);
|
|
784
1498
|
directory.writeUInt16LE(20, 4);
|
|
785
1499
|
directory.writeUInt16LE(20, 6);
|
|
786
1500
|
directory.writeUInt16LE(0x0800, 8);
|
|
787
|
-
directory.writeUInt16LE(
|
|
1501
|
+
directory.writeUInt16LE(method, 10);
|
|
788
1502
|
directory.writeUInt16LE(time, 12);
|
|
789
1503
|
directory.writeUInt16LE(date, 14);
|
|
790
1504
|
directory.writeUInt32LE(crc, 16);
|
|
791
|
-
directory.writeUInt32LE(
|
|
1505
|
+
directory.writeUInt32LE(data.length, 20);
|
|
792
1506
|
directory.writeUInt32LE(size, 24);
|
|
793
1507
|
directory.writeUInt16LE(name.length, 28);
|
|
794
1508
|
directory.writeUInt16LE(0, 30);
|
|
@@ -798,7 +1512,7 @@ const zipArchive = (files) => {
|
|
|
798
1512
|
directory.writeUInt32LE(0, 38);
|
|
799
1513
|
directory.writeUInt32LE(offset, 42);
|
|
800
1514
|
central.push(directory, name);
|
|
801
|
-
offset += local.length + name.length +
|
|
1515
|
+
offset += local.length + name.length + data.length;
|
|
802
1516
|
}
|
|
803
1517
|
const centralSize = central.reduce((size, value) => size + value.length, 0);
|
|
804
1518
|
if (offset + centralSize > 0xffffffff) throw new Error("Backup archive is too large for ZIP without ZIP64 support.");
|
|
@@ -814,20 +1528,25 @@ const zipArchive = (files) => {
|
|
|
814
1528
|
return Buffer.concat([...locals, ...central, end]);
|
|
815
1529
|
};
|
|
816
1530
|
|
|
817
|
-
|
|
1531
|
+
/** Read the files named by the previous manifest; unmanaged neighbors are never archived. */
|
|
1532
|
+
const collectBackupFiles = async (root, manifest) => {
|
|
1533
|
+
const targets = [
|
|
1534
|
+
...managedTargets(root, manifest),
|
|
1535
|
+
safeDestination(root, manifestName),
|
|
1536
|
+
safeDestination(root, textManifestName)
|
|
1537
|
+
];
|
|
818
1538
|
const files = [];
|
|
819
|
-
const
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
});
|
|
830
|
-
else throw new Error("Backup output must contain only files and directories: " + path);
|
|
1539
|
+
for (const path of [...new Set(targets)].sort()) {
|
|
1540
|
+
await assertNoSymlinks(root, path, "Backup output");
|
|
1541
|
+
let stat;
|
|
1542
|
+
try {
|
|
1543
|
+
stat = await lstat(path);
|
|
1544
|
+
} catch (error) {
|
|
1545
|
+
if (missing(error)) continue;
|
|
1546
|
+
throw error;
|
|
1547
|
+
}
|
|
1548
|
+
if (!stat.isFile()) throw new Error("Backup output must be a file: " + path);
|
|
1549
|
+
files.push({ name: portableRelative(root, path), value: await readFile(path), mtime: stat.mtime });
|
|
831
1550
|
}
|
|
832
1551
|
return files;
|
|
833
1552
|
};
|
|
@@ -836,33 +1555,47 @@ const collectBackupFiles = async (root, directory = root) => {
|
|
|
836
1555
|
export const planOutputBackup = async (outputDirectory, {
|
|
837
1556
|
outputRootDirectory = outputDirectory,
|
|
838
1557
|
backupRootDirectory = outputRootDirectory,
|
|
839
|
-
backupPath
|
|
1558
|
+
backupPath,
|
|
1559
|
+
timestampName = false
|
|
840
1560
|
} = {}) => {
|
|
841
1561
|
const output = resolve(outputDirectory);
|
|
842
1562
|
const previous = await readManifest(output, outputRootDirectory);
|
|
843
|
-
|
|
1563
|
+
// A first build has nothing to protect, so there is nothing to archive.
|
|
1564
|
+
if (!previous) return { outputDirectory: output, path: null, skipped: "no previous Ravel build" };
|
|
844
1565
|
const builtAt = previous.manifest.builtAt;
|
|
845
1566
|
const timestamp = Date.parse(builtAt);
|
|
846
1567
|
if (!Number.isFinite(timestamp)) throw new Error("Build manifest has no valid build timestamp for backup: " + previous.path);
|
|
847
1568
|
const backupRoot = resolve(backupRootDirectory);
|
|
848
1569
|
await assertDirectory(backupRoot, "Backup root");
|
|
849
|
-
const
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
1570
|
+
const seconds = Math.floor(timestamp / 1000);
|
|
1571
|
+
const named = backupPath === undefined ? null : await scopedPath(backupRoot, backupPath, "Backup path");
|
|
1572
|
+
if (named && extname(named).toLowerCase() !== ".zip") throw new Error("Backup file must have a .zip extension: " + named);
|
|
1573
|
+
// A derived name is reused by every build, so it never blocks one: when the
|
|
1574
|
+
// name is taken (two builds in one second, or a retry after a failed write),
|
|
1575
|
+
// a numeric suffix keeps every archive. An explicit name must be new.
|
|
1576
|
+
const derived = named === null || timestampName;
|
|
1577
|
+
const base = named === null
|
|
1578
|
+
? join(backupRoot, "backups", basename(output) + "-" + seconds)
|
|
1579
|
+
: timestampName ? named.slice(0, -extname(named).length) + "-" + seconds : named.slice(0, -extname(named).length);
|
|
1580
|
+
let path = base + ".zip";
|
|
1581
|
+
for (let attempt = 2; ; attempt += 1) {
|
|
1582
|
+
if (!containedIn(backupRoot, path)) throw new Error("Backup path escapes the Ravel root: " + path);
|
|
1583
|
+
await assertNoSymlinks(backupRoot, path, "Backup path");
|
|
1584
|
+
if (!await existingFile(path, "Backup file")) break;
|
|
1585
|
+
if (!derived) throw new Error("Backup file already exists: " + path);
|
|
1586
|
+
path = base + "-" + attempt + ".zip";
|
|
1587
|
+
}
|
|
856
1588
|
return { outputDirectory: output, path, manifest: previous, builtAt };
|
|
857
1589
|
};
|
|
858
1590
|
|
|
859
1591
|
/** Archive the complete current output tree before it is cleaned or replaced. */
|
|
860
1592
|
export const createOutputBackup = async (outputDirectory, options = {}) => {
|
|
861
1593
|
const plan = await planOutputBackup(outputDirectory, options);
|
|
1594
|
+
if (!plan.path) return { path: null, files: [], skipped: plan.skipped };
|
|
862
1595
|
const backupRoot = resolve(options.backupRootDirectory ?? options.outputRootDirectory ?? outputDirectory);
|
|
863
1596
|
await ensureDirectoryTree(backupRoot, dirname(plan.path), "Backup path");
|
|
864
1597
|
if (await existingFile(plan.path, "Backup file")) throw new Error("Backup file already exists: " + plan.path);
|
|
865
|
-
const files = await collectBackupFiles(plan.outputDirectory);
|
|
1598
|
+
const files = await collectBackupFiles(plan.outputDirectory, plan.manifest.manifest);
|
|
866
1599
|
const temporary = join(dirname(plan.path), "." + basename(plan.path) + ".ravel-stage-" + randomUUID());
|
|
867
1600
|
try {
|
|
868
1601
|
await writeFile(temporary, zipArchive(files));
|