@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/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 { markdownToMap } from "@pieceful/ravel-markdown";
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 readInputText = async (path, description, code) => {
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 = {}) => markdownToMap(
185
- await readInputText(path, "Markdown input", "RM201"),
186
- { uri: path, document: options.document, mode: options.mode }
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 collectPretransformMaps = async (entryPath, entryOptions = {}, scope) => {
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
- map = await readMap(absolutePath);
204
- } else if (extension === ".md" || extension === ".markdown" || extension === ".mdown") {
205
- const result = await loadMarkdownFile(absolutePath, options);
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 Markdown file.", absolutePath);
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
- await visit(resolve(dirname(absolutePath), target));
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
- return { maps, diagnostics, rootDirectory: activeScope.root };
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
- export const loadPretransformGraph = async (entryPath, options = {}) => {
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
- export const loadTomlBuild = async (configPath) => {
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(configFile, "Ravel TOML config", "RC101"));
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) || config.files.length === 0) {
263
- 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);
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
- reportUnknownKeys(config.build, new Set(["name", "out_dir", "clean", "backup"]), "build", absoluteConfig);
269
- if (config.build.name !== undefined) requireConfigString(config.build.name, "build.name", absoluteConfig);
270
- if (config.build.clean !== undefined && typeof config.build.clean !== "boolean") {
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 (config.build.backup !== undefined && typeof config.build.backup !== "boolean" &&
274
- (typeof config.build.backup !== "string" || config.build.backup.length === 0)) {
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 config.build.backup === "string" && extname(config.build.backup).toLowerCase() !== ".zip") {
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 = requireConfigString(config.build.out_dir, "build.out_dir", absoluteConfig);
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 results = await Promise.all(config.files.map(async (file, index) => {
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(["path", "document", "mode"]), "files[" + index + "]", absoluteConfig);
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
- return collectPretransformMaps(resolve(baseDirectory, path), { document: file.document, mode }, scope);
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
- const pretransform = combineMaps(results.flatMap((result) => result.maps));
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
- outputDirectory: await scope.path(outDirectory, "build.out_dir"),
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: config.build.clean === true,
309
- backup: config.build.backup ?? false
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 Markdown document, or a single Ravel TOML build run. */
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
- const rootDirectory = dirname(resolve(inputPath));
320
- return {
321
- pretransform: await loadPretransformGraph(resolve(inputPath), options),
322
- outputDirectory: undefined,
323
- rootDirectory
324
- };
325
- }
326
- if (extension === ".json") {
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: await loadPretransformGraph(inputPath),
985
+ pretransform: graphFromCollectedMaps(collected),
329
986
  outputDirectory: undefined,
330
- rootDirectory: dirname(resolve(inputPath))
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, Markdown document, or .toml build config.", inputPath);
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
- 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
+ };
363
1032
  const deliverables = orderedDeliverables(program).map((deliverable) => {
364
1033
  const destination = safeDestination(root, deliverable.name);
365
- if (destinations.has(destination)) throw new Error("Multiple deliverables resolve to the same destination: " + deliverable.name);
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
- const mapDestination = safeDestination(root, ravelmap);
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
- const aggregateDestination = safeDestination(root, aggregateProvenanceName);
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
- await writeFile(temporary, entry.value, "utf8");
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
- if (entry.committed) await removeIfPresent(entry.destination);
527
- 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
+ ]) {
528
1196
  try {
529
- await rename(entry.backup, entry.destination);
1197
+ await step();
530
1198
  } catch (rollbackError) {
531
- if (!missing(rollbackError)) throw rollbackError;
1199
+ if (!missing(rollbackError)) rollbackErrors.push(rollbackError);
532
1200
  }
533
1201
  }
534
- await removeIfPresent(entry.temporary);
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
- for (const target of targets) await existingFile(target, "Managed output path");
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(0, 8);
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(size, 18);
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, file.value);
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(0, 10);
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(size, 20);
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 + size;
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
- 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
+ ];
818
1538
  const files = [];
819
- const entries = await readdir(directory, { withFileTypes: true });
820
- for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
821
- const path = join(directory, entry.name);
822
- const stat = await lstat(path);
823
- if (stat.isSymbolicLink()) throw new Error("Backup output must not traverse a symbolic link: " + path);
824
- if (stat.isDirectory()) files.push(...await collectBackupFiles(root, path));
825
- else if (stat.isFile()) files.push({
826
- name: relative(root, path).split(sep).join("/"),
827
- value: await readFile(path),
828
- mtime: stat.mtime
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
- 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" };
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 path = backupPath === undefined
850
- ? join(backupRoot, "backups", basename(output) + "-" + Math.floor(timestamp / 1000) + ".zip")
851
- : await scopedPath(backupRoot, backupPath, "Backup path");
852
- if (extname(path).toLowerCase() !== ".zip") throw new Error("Backup file must have a .zip extension: " + path);
853
- if (!containedIn(backupRoot, path)) throw new Error("Backup path escapes the Ravel root: " + path);
854
- await assertNoSymlinks(backupRoot, path, "Backup path");
855
- 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
+ }
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));