@pieceful/ravel-host-node 0.2.0 → 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/package.json +12 -11
- package/src/index.js +403 -59
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pieceful/ravel-host-node",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Node host capabilities for Ravel.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -36,15 +36,16 @@
|
|
|
36
36
|
"LICENSE"
|
|
37
37
|
],
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"@pieceful/ravel-asciidoc": "0.
|
|
40
|
-
"@pieceful/ravel-core": "0.
|
|
41
|
-
"@pieceful/ravel-html": "0.
|
|
42
|
-
"@pieceful/ravel-
|
|
43
|
-
"@pieceful/ravel-markdown
|
|
44
|
-
"@pieceful/ravel-
|
|
45
|
-
"@pieceful/ravel-myst": "0.
|
|
46
|
-
"@pieceful/ravel-noweb": "0.
|
|
47
|
-
"@pieceful/ravel-org": "0.
|
|
48
|
-
"
|
|
39
|
+
"@pieceful/ravel-asciidoc": "0.3.0",
|
|
40
|
+
"@pieceful/ravel-core": "0.3.0",
|
|
41
|
+
"@pieceful/ravel-html": "0.3.0",
|
|
42
|
+
"@pieceful/ravel-map": "0.3.0",
|
|
43
|
+
"@pieceful/ravel-markdown": "0.3.0",
|
|
44
|
+
"@pieceful/ravel-markdown-litpro": "0.3.0",
|
|
45
|
+
"@pieceful/ravel-myst": "0.3.0",
|
|
46
|
+
"@pieceful/ravel-noweb": "0.3.0",
|
|
47
|
+
"@pieceful/ravel-org": "0.3.0",
|
|
48
|
+
"@pieceful/ravel-transforms": "0.3.0",
|
|
49
|
+
"smol-toml": "^1.9.1"
|
|
49
50
|
}
|
|
50
51
|
}
|
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 {
|
|
@@ -10,14 +11,17 @@ import {
|
|
|
10
11
|
} from "@pieceful/ravel-core";
|
|
11
12
|
import { asciidocToMap } from "@pieceful/ravel-asciidoc";
|
|
12
13
|
import { htmlToMap } from "@pieceful/ravel-html";
|
|
13
|
-
import { markdownToMap } from "@pieceful/ravel-markdown";
|
|
14
|
+
import { markdownToMap, parseFrontMatter } from "@pieceful/ravel-markdown";
|
|
14
15
|
import { isLitproMarkdown, litproMarkdownToMap } from "@pieceful/ravel-markdown-litpro";
|
|
15
16
|
import { assertRavelMap } from "@pieceful/ravel-map";
|
|
16
17
|
import { mystToMap } from "@pieceful/ravel-myst";
|
|
17
18
|
import { nowebToMap } from "@pieceful/ravel-noweb";
|
|
18
19
|
import { orgToMap } from "@pieceful/ravel-org";
|
|
20
|
+
import { builtinTransforms } from "@pieceful/ravel-transforms";
|
|
21
|
+
import { pathToFileURL } from "node:url";
|
|
19
22
|
|
|
20
23
|
const missing = (error) => error?.code === "ENOENT";
|
|
24
|
+
const componentPattern = /^[a-z][a-z0-9-]*$/;
|
|
21
25
|
|
|
22
26
|
const throwIfAborted = (signal) => {
|
|
23
27
|
if (!signal?.aborted) return;
|
|
@@ -298,6 +302,7 @@ const collectPretransformMaps = async (
|
|
|
298
302
|
const activeScope = scope ?? await createInputScope(dirname(resolve(entryPath)));
|
|
299
303
|
const visited = new Set();
|
|
300
304
|
const maps = [];
|
|
305
|
+
const mapPaths = [];
|
|
301
306
|
const diagnostics = [];
|
|
302
307
|
const authoredSourcePaths = new Set();
|
|
303
308
|
const authoredSourceTexts = new Map();
|
|
@@ -390,12 +395,14 @@ const collectPretransformMaps = async (
|
|
|
390
395
|
});
|
|
391
396
|
}
|
|
392
397
|
maps.push(map);
|
|
398
|
+
mapPaths.push(absolutePath);
|
|
393
399
|
};
|
|
394
400
|
|
|
395
401
|
await visit(entryPath, entryOptions);
|
|
396
402
|
throwIfAborted(signal);
|
|
397
403
|
return {
|
|
398
404
|
maps,
|
|
405
|
+
mapPaths,
|
|
399
406
|
diagnostics,
|
|
400
407
|
rootDirectory: activeScope.root,
|
|
401
408
|
authoredSourcePaths: [...authoredSourcePaths].sort(),
|
|
@@ -455,6 +462,252 @@ const reportUnknownKeys = (value, allowed, description, configPath) => {
|
|
|
455
462
|
}
|
|
456
463
|
};
|
|
457
464
|
|
|
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
|
+
|
|
458
711
|
const loadLiveConfiguration = async (
|
|
459
712
|
live,
|
|
460
713
|
scope,
|
|
@@ -544,10 +797,13 @@ export const loadTomlBuild = async (configPath, options = {}) => {
|
|
|
544
797
|
if (Array.isArray(error?.diagnostics)) throw error;
|
|
545
798
|
throw inputError("RC101", "Invalid Ravel TOML config: " + (error?.message ?? String(error)), absoluteConfig);
|
|
546
799
|
}
|
|
547
|
-
reportUnknownKeys(config, new Set(["version", "files", "build", "outputs", "live"]), "config", absoluteConfig);
|
|
800
|
+
reportUnknownKeys(config, new Set(["version", "files", "collections", "build", "outputs", "live", "transforms"]), "config", absoluteConfig);
|
|
548
801
|
if (config.version !== 1) throw inputError("RC102", "version must be 1.", absoluteConfig);
|
|
549
|
-
if (!Array.isArray(config.files)
|
|
550
|
-
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);
|
|
551
807
|
}
|
|
552
808
|
if (config.build !== undefined && (!config.build || typeof config.build !== "object" || Array.isArray(config.build))) {
|
|
553
809
|
throw inputError("RC102", "build must be a [build] table.", absoluteConfig);
|
|
@@ -576,7 +832,7 @@ export const loadTomlBuild = async (configPath, options = {}) => {
|
|
|
576
832
|
options.overlays,
|
|
577
833
|
{ readResources: options.readLiveResources !== false }
|
|
578
834
|
);
|
|
579
|
-
const results = await Promise.all(config.files.map(async (file, index) => {
|
|
835
|
+
const results = await Promise.all((config.files ?? []).map(async (file, index) => {
|
|
580
836
|
if (!file || typeof file !== "object") throw inputError("RC102", "files[" + index + "] must be a table.", absoluteConfig);
|
|
581
837
|
reportUnknownKeys(file, new Set([
|
|
582
838
|
"path", "document", "mode", "profile", "adapter", "dialect",
|
|
@@ -662,7 +918,16 @@ export const loadTomlBuild = async (configPath, options = {}) => {
|
|
|
662
918
|
}, scope, options.overlays);
|
|
663
919
|
}));
|
|
664
920
|
throwIfAborted(options.signal);
|
|
665
|
-
|
|
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]);
|
|
666
931
|
pretransform.diagnostics.push(...results.flatMap((result) => result.diagnostics));
|
|
667
932
|
for (const output of config.outputs ?? []) {
|
|
668
933
|
if (!output || typeof output !== "object") throw inputError("RC102", "Each [[outputs]] entry must be a table.", absoluteConfig);
|
|
@@ -676,11 +941,17 @@ export const loadTomlBuild = async (configPath, options = {}) => {
|
|
|
676
941
|
}
|
|
677
942
|
return {
|
|
678
943
|
pretransform: relativizeSourceUris(pretransform, scope.root),
|
|
679
|
-
authoredSourceUris: [...new Set(
|
|
680
|
-
|
|
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
|
+
)),
|
|
681
951
|
loadedInputUris: [...new Set([
|
|
682
952
|
relative(scope.root, absoluteConfig).split(sep).join("/"),
|
|
683
|
-
...results.flatMap(loadedInputUris)
|
|
953
|
+
...results.flatMap(loadedInputUris),
|
|
954
|
+
...collections.inputPaths.map((path) => portableRelative(scope.root, path))
|
|
684
955
|
])].sort(),
|
|
685
956
|
sourceEditsAllowed: results.every((result) => !result.containsJsonMaps),
|
|
686
957
|
...(outDirectory === undefined ? {} : { outputDirectory: await scope.path(outDirectory, "build.out_dir") }),
|
|
@@ -689,6 +960,7 @@ export const loadTomlBuild = async (configPath, options = {}) => {
|
|
|
689
960
|
clean: build.clean === true,
|
|
690
961
|
backup: build.backup ?? false
|
|
691
962
|
},
|
|
963
|
+
transforms: await loadProjectTransforms(config.transforms, scope, absoluteConfig),
|
|
692
964
|
...(live ? { live } : {})
|
|
693
965
|
};
|
|
694
966
|
};
|
|
@@ -716,7 +988,8 @@ export const loadBuildInput = async (inputPath, options = {}) => {
|
|
|
716
988
|
authoredSourceUris: authoredSourceUris(collected),
|
|
717
989
|
authoredSourceTexts: authoredSourceTexts(collected),
|
|
718
990
|
loadedInputUris: loadedInputUris(collected),
|
|
719
|
-
sourceEditsAllowed: !collected.containsJsonMaps
|
|
991
|
+
sourceEditsAllowed: !collected.containsJsonMaps,
|
|
992
|
+
transforms: { ...builtinTransforms }
|
|
720
993
|
};
|
|
721
994
|
}
|
|
722
995
|
throw inputError("RH101", "Ravel input must be a .json map, supported markup document, or .toml build config.", inputPath);
|
|
@@ -748,19 +1021,21 @@ const sidecarName = (name) => name + ".ravelmap";
|
|
|
748
1021
|
/** Build a deterministic, non-writing description of artifact output. */
|
|
749
1022
|
export const planDeliverables = (program, outputDirectory) => {
|
|
750
1023
|
const root = resolve(outputDirectory);
|
|
751
|
-
|
|
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
|
+
};
|
|
752
1032
|
const deliverables = orderedDeliverables(program).map((deliverable) => {
|
|
753
1033
|
const destination = safeDestination(root, deliverable.name);
|
|
754
|
-
|
|
755
|
-
destinations.add(destination);
|
|
1034
|
+
claim(destination, "Deliverable collides with another output or Ravel's manifest (names are compared case-insensitively): " + deliverable.name);
|
|
756
1035
|
const hasProvenance = Array.isArray(deliverable.segments);
|
|
757
1036
|
const ravelmap = hasProvenance ? sidecarName(deliverable.name) : null;
|
|
758
1037
|
if (ravelmap) {
|
|
759
|
-
|
|
760
|
-
if (destinations.has(mapDestination)) {
|
|
761
|
-
throw new Error("A deliverable provenance map collides with another output: " + ravelmap);
|
|
762
|
-
}
|
|
763
|
-
destinations.add(mapDestination);
|
|
1038
|
+
claim(safeDestination(root, ravelmap), "A deliverable provenance map collides with another output: " + ravelmap);
|
|
764
1039
|
}
|
|
765
1040
|
return {
|
|
766
1041
|
name: deliverable.name,
|
|
@@ -772,10 +1047,7 @@ export const planDeliverables = (program, outputDirectory) => {
|
|
|
772
1047
|
};
|
|
773
1048
|
});
|
|
774
1049
|
if (deliverables.some((deliverable) => deliverable.ravelmap)) {
|
|
775
|
-
|
|
776
|
-
if (destinations.has(aggregateDestination)) {
|
|
777
|
-
throw new Error("The aggregate provenance map collides with another output: " + aggregateProvenanceName);
|
|
778
|
-
}
|
|
1050
|
+
claim(safeDestination(root, aggregateProvenanceName), "The aggregate provenance map collides with another output: " + aggregateProvenanceName);
|
|
779
1051
|
}
|
|
780
1052
|
return {
|
|
781
1053
|
version: 1,
|
|
@@ -887,8 +1159,9 @@ const writeFilesAtomically = async (scope, entries, description) => {
|
|
|
887
1159
|
const token = randomUUID();
|
|
888
1160
|
const temporary = join(dirname(entry.destination), "." + basename(entry.destination) + ".ravel-stage-" + token);
|
|
889
1161
|
const backup = join(dirname(entry.destination), "." + basename(entry.destination) + ".ravel-backup-" + token);
|
|
890
|
-
|
|
1162
|
+
// Record the entry before writing so a failed write still removes its temporary file.
|
|
891
1163
|
staged.push({ ...entry, exists, temporary, backup, committed: false, backedUp: false });
|
|
1164
|
+
await writeFile(temporary, entry.value, "utf8");
|
|
892
1165
|
}
|
|
893
1166
|
|
|
894
1167
|
for (const entry of staged) {
|
|
@@ -911,16 +1184,25 @@ const writeFilesAtomically = async (scope, entries, description) => {
|
|
|
911
1184
|
}
|
|
912
1185
|
}
|
|
913
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 = [];
|
|
914
1190
|
for (const entry of staged.slice().reverse()) {
|
|
915
|
-
|
|
916
|
-
|
|
1191
|
+
for (const step of [
|
|
1192
|
+
() => entry.committed && removeIfPresent(entry.destination),
|
|
1193
|
+
() => entry.backedUp && rename(entry.backup, entry.destination),
|
|
1194
|
+
() => removeIfPresent(entry.temporary)
|
|
1195
|
+
]) {
|
|
917
1196
|
try {
|
|
918
|
-
await
|
|
1197
|
+
await step();
|
|
919
1198
|
} catch (rollbackError) {
|
|
920
|
-
if (!missing(rollbackError))
|
|
1199
|
+
if (!missing(rollbackError)) rollbackErrors.push(rollbackError);
|
|
921
1200
|
}
|
|
922
1201
|
}
|
|
923
|
-
|
|
1202
|
+
}
|
|
1203
|
+
if (rollbackErrors.length) {
|
|
1204
|
+
error.message += " (rollback incomplete: " + rollbackErrors.map((entry) => entry.message).join("; ") + ")";
|
|
1205
|
+
error.rollbackErrors = rollbackErrors;
|
|
924
1206
|
}
|
|
925
1207
|
throw error;
|
|
926
1208
|
}
|
|
@@ -1011,10 +1293,14 @@ export const writeBuildManifest = async (program, outputDirectory, {
|
|
|
1011
1293
|
export const writeBuildArtifacts = async (program, outputDirectory, {
|
|
1012
1294
|
rootDirectory = outputDirectory,
|
|
1013
1295
|
stale = [],
|
|
1296
|
+
clean = false,
|
|
1014
1297
|
generatedAt = new Date().toISOString()
|
|
1015
1298
|
} = {}) => {
|
|
1016
1299
|
const scope = await createOutputScope(outputDirectory, rootDirectory);
|
|
1017
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;
|
|
1018
1304
|
const manifest = createBuildManifest(program, scope.root, { stale, builtAt: generatedAt });
|
|
1019
1305
|
const deliverableEntries = plan.deliverables.map((deliverable) => ({
|
|
1020
1306
|
destination: safeDestination(scope.root, deliverable.name),
|
|
@@ -1043,8 +1329,27 @@ export const writeBuildArtifacts = async (program, outputDirectory, {
|
|
|
1043
1329
|
{ destination: manifestDestination, value: JSON.stringify(manifest, null, 2) + "\n" },
|
|
1044
1330
|
{ destination: textManifestDestination, value: formatTextManifest(manifest, generatedAt) }
|
|
1045
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);
|
|
1046
1346
|
return {
|
|
1047
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)),
|
|
1048
1353
|
provenance: {
|
|
1049
1354
|
sidecars: provenanceEntries.map((entry) => entry.destination),
|
|
1050
1355
|
aggregate: aggregateEntry[0]?.destination ?? null
|
|
@@ -1076,6 +1381,23 @@ const planManagedRemoval = async (outputDirectory, rootDirectory, staleOnly) =>
|
|
|
1076
1381
|
};
|
|
1077
1382
|
};
|
|
1078
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
|
+
|
|
1079
1401
|
const removeManagedFiles = async (plan, rootDirectory, dryRun, removeManifest) => {
|
|
1080
1402
|
if (!plan.manifest || dryRun) return { removed: plan.deliverables, manifestRemoved: false };
|
|
1081
1403
|
const scope = await createOutputScope(plan.outputDirectory, rootDirectory);
|
|
@@ -1087,8 +1409,7 @@ const removeManagedFiles = async (plan, rootDirectory, dryRun, removeManifest) =
|
|
|
1087
1409
|
...(plan.aggregate ? [safeDestination(scope.root, plan.aggregate)] : []),
|
|
1088
1410
|
...(removeManifest ? [safeDestination(scope.root, manifestName), safeDestination(scope.root, textManifestName)] : [])
|
|
1089
1411
|
];
|
|
1090
|
-
|
|
1091
|
-
for (const target of targets) await removeIfPresent(target);
|
|
1412
|
+
await removeManagedTargets(scope, targets);
|
|
1092
1413
|
return { removed: plan.deliverables, manifestRemoved: removeManifest };
|
|
1093
1414
|
};
|
|
1094
1415
|
|
|
@@ -1154,30 +1475,34 @@ const zipArchive = (files) => {
|
|
|
1154
1475
|
}
|
|
1155
1476
|
const { date, time } = zipDateTime(file.mtime);
|
|
1156
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;
|
|
1157
1482
|
const local = Buffer.alloc(30);
|
|
1158
1483
|
local.writeUInt32LE(0x04034b50, 0);
|
|
1159
1484
|
local.writeUInt16LE(20, 4);
|
|
1160
1485
|
local.writeUInt16LE(0x0800, 6);
|
|
1161
|
-
local.writeUInt16LE(
|
|
1486
|
+
local.writeUInt16LE(method, 8);
|
|
1162
1487
|
local.writeUInt16LE(time, 10);
|
|
1163
1488
|
local.writeUInt16LE(date, 12);
|
|
1164
1489
|
local.writeUInt32LE(crc, 14);
|
|
1165
|
-
local.writeUInt32LE(
|
|
1490
|
+
local.writeUInt32LE(data.length, 18);
|
|
1166
1491
|
local.writeUInt32LE(size, 22);
|
|
1167
1492
|
local.writeUInt16LE(name.length, 26);
|
|
1168
1493
|
local.writeUInt16LE(0, 28);
|
|
1169
|
-
locals.push(local, name,
|
|
1494
|
+
locals.push(local, name, data);
|
|
1170
1495
|
|
|
1171
1496
|
const directory = Buffer.alloc(46);
|
|
1172
1497
|
directory.writeUInt32LE(0x02014b50, 0);
|
|
1173
1498
|
directory.writeUInt16LE(20, 4);
|
|
1174
1499
|
directory.writeUInt16LE(20, 6);
|
|
1175
1500
|
directory.writeUInt16LE(0x0800, 8);
|
|
1176
|
-
directory.writeUInt16LE(
|
|
1501
|
+
directory.writeUInt16LE(method, 10);
|
|
1177
1502
|
directory.writeUInt16LE(time, 12);
|
|
1178
1503
|
directory.writeUInt16LE(date, 14);
|
|
1179
1504
|
directory.writeUInt32LE(crc, 16);
|
|
1180
|
-
directory.writeUInt32LE(
|
|
1505
|
+
directory.writeUInt32LE(data.length, 20);
|
|
1181
1506
|
directory.writeUInt32LE(size, 24);
|
|
1182
1507
|
directory.writeUInt16LE(name.length, 28);
|
|
1183
1508
|
directory.writeUInt16LE(0, 30);
|
|
@@ -1187,7 +1512,7 @@ const zipArchive = (files) => {
|
|
|
1187
1512
|
directory.writeUInt32LE(0, 38);
|
|
1188
1513
|
directory.writeUInt32LE(offset, 42);
|
|
1189
1514
|
central.push(directory, name);
|
|
1190
|
-
offset += local.length + name.length +
|
|
1515
|
+
offset += local.length + name.length + data.length;
|
|
1191
1516
|
}
|
|
1192
1517
|
const centralSize = central.reduce((size, value) => size + value.length, 0);
|
|
1193
1518
|
if (offset + centralSize > 0xffffffff) throw new Error("Backup archive is too large for ZIP without ZIP64 support.");
|
|
@@ -1203,20 +1528,25 @@ const zipArchive = (files) => {
|
|
|
1203
1528
|
return Buffer.concat([...locals, ...central, end]);
|
|
1204
1529
|
};
|
|
1205
1530
|
|
|
1206
|
-
|
|
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
|
+
];
|
|
1207
1538
|
const files = [];
|
|
1208
|
-
const
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
});
|
|
1219
|
-
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 });
|
|
1220
1550
|
}
|
|
1221
1551
|
return files;
|
|
1222
1552
|
};
|
|
@@ -1225,33 +1555,47 @@ const collectBackupFiles = async (root, directory = root) => {
|
|
|
1225
1555
|
export const planOutputBackup = async (outputDirectory, {
|
|
1226
1556
|
outputRootDirectory = outputDirectory,
|
|
1227
1557
|
backupRootDirectory = outputRootDirectory,
|
|
1228
|
-
backupPath
|
|
1558
|
+
backupPath,
|
|
1559
|
+
timestampName = false
|
|
1229
1560
|
} = {}) => {
|
|
1230
1561
|
const output = resolve(outputDirectory);
|
|
1231
1562
|
const previous = await readManifest(output, outputRootDirectory);
|
|
1232
|
-
|
|
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" };
|
|
1233
1565
|
const builtAt = previous.manifest.builtAt;
|
|
1234
1566
|
const timestamp = Date.parse(builtAt);
|
|
1235
1567
|
if (!Number.isFinite(timestamp)) throw new Error("Build manifest has no valid build timestamp for backup: " + previous.path);
|
|
1236
1568
|
const backupRoot = resolve(backupRootDirectory);
|
|
1237
1569
|
await assertDirectory(backupRoot, "Backup root");
|
|
1238
|
-
const
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
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
|
+
}
|
|
1245
1588
|
return { outputDirectory: output, path, manifest: previous, builtAt };
|
|
1246
1589
|
};
|
|
1247
1590
|
|
|
1248
1591
|
/** Archive the complete current output tree before it is cleaned or replaced. */
|
|
1249
1592
|
export const createOutputBackup = async (outputDirectory, options = {}) => {
|
|
1250
1593
|
const plan = await planOutputBackup(outputDirectory, options);
|
|
1594
|
+
if (!plan.path) return { path: null, files: [], skipped: plan.skipped };
|
|
1251
1595
|
const backupRoot = resolve(options.backupRootDirectory ?? options.outputRootDirectory ?? outputDirectory);
|
|
1252
1596
|
await ensureDirectoryTree(backupRoot, dirname(plan.path), "Backup path");
|
|
1253
1597
|
if (await existingFile(plan.path, "Backup file")) throw new Error("Backup file already exists: " + plan.path);
|
|
1254
|
-
const files = await collectBackupFiles(plan.outputDirectory);
|
|
1598
|
+
const files = await collectBackupFiles(plan.outputDirectory, plan.manifest.manifest);
|
|
1255
1599
|
const temporary = join(dirname(plan.path), "." + basename(plan.path) + ".ravel-stage-" + randomUUID());
|
|
1256
1600
|
try {
|
|
1257
1601
|
await writeFile(temporary, zipArchive(files));
|