@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.
Files changed (2) hide show
  1. package/package.json +12 -11
  2. 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.2.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.2.0",
40
- "@pieceful/ravel-core": "0.2.0",
41
- "@pieceful/ravel-html": "0.2.0",
42
- "@pieceful/ravel-markdown": "0.2.0",
43
- "@pieceful/ravel-markdown-litpro": "0.2.0",
44
- "@pieceful/ravel-map": "0.2.0",
45
- "@pieceful/ravel-myst": "0.2.0",
46
- "@pieceful/ravel-noweb": "0.2.0",
47
- "@pieceful/ravel-org": "0.2.0",
48
- "smol-toml": "^1.7.0"
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) || config.files.length === 0) {
550
- throw inputError("RC102", "files must contain one or more [[files]] entries.", absoluteConfig);
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
- const pretransform = combineMaps(results.flatMap((result) => result.maps));
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(results.flatMap(authoredSourceUris))].sort(),
680
- authoredSourceTexts: Object.assign({}, ...results.map(authoredSourceTexts)),
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
- const destinations = new Set();
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
- if (destinations.has(destination)) throw new Error("Multiple deliverables resolve to the same destination: " + deliverable.name);
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
- const mapDestination = safeDestination(root, ravelmap);
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
- const aggregateDestination = safeDestination(root, aggregateProvenanceName);
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
- await writeFile(temporary, entry.value, "utf8");
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
- if (entry.committed) await removeIfPresent(entry.destination);
916
- if (entry.backedUp) {
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 rename(entry.backup, entry.destination);
1197
+ await step();
919
1198
  } catch (rollbackError) {
920
- if (!missing(rollbackError)) throw rollbackError;
1199
+ if (!missing(rollbackError)) rollbackErrors.push(rollbackError);
921
1200
  }
922
1201
  }
923
- await removeIfPresent(entry.temporary);
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
- for (const target of targets) await existingFile(target, "Managed output path");
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(0, 8);
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(size, 18);
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, file.value);
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(0, 10);
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(size, 20);
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 + size;
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
- const collectBackupFiles = async (root, directory = root) => {
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 entries = await readdir(directory, { withFileTypes: true });
1209
- for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
1210
- const path = join(directory, entry.name);
1211
- const stat = await lstat(path);
1212
- if (stat.isSymbolicLink()) throw new Error("Backup output must not traverse a symbolic link: " + path);
1213
- if (stat.isDirectory()) files.push(...await collectBackupFiles(root, path));
1214
- else if (stat.isFile()) files.push({
1215
- name: relative(root, path).split(sep).join("/"),
1216
- value: await readFile(path),
1217
- mtime: stat.mtime
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
- if (!previous) throw new Error("Cannot create a backup without an existing Ravel build manifest: " + output);
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 path = backupPath === undefined
1239
- ? join(backupRoot, "backups", basename(output) + "-" + Math.floor(timestamp / 1000) + ".zip")
1240
- : await scopedPath(backupRoot, backupPath, "Backup path");
1241
- if (extname(path).toLowerCase() !== ".zip") throw new Error("Backup file must have a .zip extension: " + path);
1242
- if (!containedIn(backupRoot, path)) throw new Error("Backup path escapes the Ravel root: " + path);
1243
- await assertNoSymlinks(backupRoot, path, "Backup path");
1244
- if (await existingFile(path, "Backup file")) throw new Error("Backup file already exists: " + path);
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));