@pieceful/ravel-host-node 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE CHANGED
@@ -2,8 +2,20 @@ MIT License
2
2
 
3
3
  Copyright (c) 2026 James Taylor
4
4
 
5
- Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
6
11
 
7
- The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
8
14
 
9
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,96 @@
1
+ # @pieceful/ravel-host-node
2
+
3
+ The Node.js host for [Ravel](https://github.com/jostylr/ravel) projects. It loads Markdown, JSON Ravel Maps, and
4
+ TOML project configuration; confines project filesystem access; and safely
5
+ writes planned deliverables, manifests, provenance maps, cleanups, and backups.
6
+
7
+ ```sh
8
+ npm install @pieceful/ravel-host-node
9
+ ```
10
+
11
+ Use `loadBuildInput` to load a project and `writeBuildArtifacts` to write a
12
+ completed program, or use the higher-level `@pieceful/ravel` CLI. Inputs and
13
+ outputs are contained beneath an explicit project root; path escapes and
14
+ symlink traversal are rejected.
15
+
16
+ Editor hosts can evaluate unsaved project state by supplying absolute,
17
+ normalized in-memory overlays. Imported inputs and configuration consult the
18
+ overlay before disk:
19
+
20
+ ```js
21
+ const loaded = await loadBuildInput("ravel.toml", {
22
+ overlays: new Map([
23
+ [absoluteMarkdownPath, { text: dirtyEditorText, version: 12 }],
24
+ ]),
25
+ });
26
+ ```
27
+
28
+ Loading overlays never writes source, deliverables, manifests, provenance
29
+ sidecars, backups, or stale-output changes. Artifact writes remain separate,
30
+ explicit host operations.
31
+
32
+ `loadBuildInput` reports `loadedInputUris` for invalidation and separately
33
+ reports `authoredSourceUris` plus `authoredSourceTexts`. The text record contains
34
+ the exact overlay or disk text consumed for each authored non-JSON input, keyed
35
+ relative to the project root. Editor hosts can use it to prove that a newly
36
+ opened clean navigation destination still matches the evaluated snapshot; it
37
+ does not supply an editor version or write permission. A source path merely
38
+ asserted by JSON Ravel Map data is not added to the authored set, and a load
39
+ containing JSON map provenance reports `sourceEditsAllowed: false` for the
40
+ current conservative automatic-edit policy.
41
+
42
+ Version-1 TOML projects may declare text `[[live.resources]]` and installed
43
+ package exports in `[[live.modules]]`. The host validates and loads those
44
+ declarations; provider-specific package preparation remains outside this
45
+ general filesystem host.
46
+
47
+ `.qmd` inputs are parsed through the modern Markdown adapter before any Quarto
48
+ execution stage. Markdown entries in `ravel.toml` may select
49
+ `profile = "modern"` or retain the compatibility profile with
50
+ `profile = "fences"`.
51
+
52
+ Historical projects select the independent LitPro adapter with
53
+ `adapter = "markdown-litpro"` and may choose `dialect = "litpro-2017"`,
54
+ `"pieceful-2020"`, or `"litpro-plus"`. Legacy `load:` directives retain the
55
+ adapter and dialect while loading their declared input; parsing never performs
56
+ other legacy effects.
57
+
58
+ Noweb sources use `.nw` or `.noweb`, or may be selected explicitly with
59
+ `adapter = "noweb"`. TOML accepts `dialect = "noweb"` or `"noweb-plus"`,
60
+ `references = "noweb"`, `"underscore-quote"`, or `"both"`, and an optional
61
+ `language`. `run = true` and `provider = "..."` retain live execution intent
62
+ as chunk metadata; loading never executes a noweb chunk or writes tangled
63
+ output.
64
+
65
+ AsciiDoc sources use `.adoc`, `.asciidoc`, or `adapter = "asciidoc"`.
66
+ Section and block pieces retain exact source bodies, pipelines, native
67
+ cross-reference navigation, and `ravel::` graph directives. Loading does not
68
+ invoke Asciidoctor or expand includes.
69
+
70
+ HTML sources use `.html`, `.htm`, or `adapter = "html"`. Semantic sections and
71
+ figures retain visible names, decoded code, native navigation, pipelines, and
72
+ directive links. Loading uses authored source with scripting disabled; it
73
+ never constructs a browser DOM or observes runtime mutations.
74
+
75
+ Org sources use `.org` or `adapter = "org"`. TOML can select
76
+ `references = "org-noweb"`, `"underscore-quote"`, or `"both"`, enable
77
+ Ravel-only use-site pipes with `noweb_pipes = true`, and assign Babel
78
+ authority with `execution_owner = "org"` or `"ravel"`. The host retains
79
+ Babel header arguments, result artifacts, and tangle requests as data; loading
80
+ does not invoke Emacs or Babel.
81
+
82
+ MyST sources use `.myst.md` or `adapter = "myst"`. Labeled `{ravel:piece}`,
83
+ `{code}`, `{code-block}`, and `{code-cell}` directives retain visible
84
+ captions, anchors, exact bodies, cross-reference navigation, front matter, and
85
+ notebook tags. TOML may assign cell authority with
86
+ `execution_owner = "myst"` or `"ravel"`; loading invokes neither MyST nor
87
+ Jupyter.
88
+
89
+ Requires Node.js 22 or newer. This package is intentionally Node-specific;
90
+ browser and Bun hosts should supply their own I/O boundary around
91
+ `@pieceful/ravel-core`.
92
+
93
+ See the [Ravel documentation](https://ravel.jostylr.com/) for TOML configuration,
94
+ filesystem safety, manifests, cleanup, and backup behavior.
95
+
96
+ MIT © James Taylor
package/package.json CHANGED
@@ -1,25 +1,50 @@
1
1
  {
2
2
  "name": "@pieceful/ravel-host-node",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "description": "Node host capabilities for Ravel.",
6
6
  "license": "MIT",
7
7
  "author": "James Taylor",
8
- "repository": { "type": "git", "url": "git+https://github.com/jostylr/ravel.git", "directory": "packages/host-node" },
9
- "bugs": { "url": "https://github.com/jostylr/ravel/issues" },
10
- "homepage": "https://github.com/jostylr/ravel#readme",
11
- "engines": { "node": ">=22" },
12
- "keywords": ["ravel", "literate-programming", "node", "build"],
13
- "publishConfig": { "access": "public" },
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/jostylr/ravel.git",
11
+ "directory": "packages/host-node"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/jostylr/ravel/issues"
15
+ },
16
+ "homepage": "https://ravel.jostylr.com/",
17
+ "engines": {
18
+ "node": ">=22"
19
+ },
20
+ "keywords": [
21
+ "ravel",
22
+ "literate-programming",
23
+ "node",
24
+ "build"
25
+ ],
26
+ "publishConfig": {
27
+ "access": "public"
28
+ },
14
29
  "exports": {
15
30
  ".": "./src/index.js"
16
31
  },
17
32
  "types": "./src/index.d.ts",
18
- "files": ["src", "LICENSE"],
33
+ "files": [
34
+ "src",
35
+ "README.md",
36
+ "LICENSE"
37
+ ],
19
38
  "dependencies": {
20
- "@pieceful/ravel-core": "0.1.0",
21
- "@pieceful/ravel-markdown": "0.1.0",
22
- "@pieceful/ravel-map": "0.1.0",
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",
23
48
  "smol-toml": "^1.7.0"
24
49
  }
25
50
  }
package/src/index.d.ts CHANGED
@@ -3,11 +3,45 @@ export interface SourceRange { start: SourcePosition; end: SourcePosition; }
3
3
  export interface SourceLocation { uri: string; range: SourceRange; }
4
4
  export interface Diagnostic { code: string; severity: "error" | "warning" | "info"; message: string; source: SourceLocation; }
5
5
  export interface RavelProgram { version?: number; deliverables: Record<string, { name: string; from: string; value: string; segments?: unknown[] }>; }
6
- export interface BuildInput { pretransform: unknown; outputDirectory?: string; rootDirectory: string; buildOptions?: { clean: boolean; backup: boolean | string }; }
6
+ export interface LiveModuleDeclaration { specifier: string; from: string; source?: SourceLocation; }
7
+ export interface LiveResourceDeclaration { name: string; path: string; source?: SourceLocation; }
8
+ export interface LiveConfiguration { modules: LiveModuleDeclaration[]; resources: Record<string, string>; resourceDeclarations?: LiveResourceDeclaration[]; }
9
+ export interface BuildInput {
10
+ pretransform: unknown;
11
+ outputDirectory?: string;
12
+ rootDirectory: string;
13
+ /** Relative source documents actually parsed through a source adapter. */
14
+ authoredSourceUris: string[];
15
+ /** Exact authored text consumed by source adapters for this evaluation. */
16
+ authoredSourceTexts: Record<string, string>;
17
+ /** Relative JSON/markup/config inputs whose overlays invalidate analysis. */
18
+ loadedInputUris: string[];
19
+ /** False when any supplied JSON map makes automatic edit provenance unverified. */
20
+ sourceEditsAllowed: boolean;
21
+ buildOptions?: { clean: boolean; backup: boolean | string };
22
+ live?: LiveConfiguration;
23
+ }
24
+ export interface SourceAdapterOptions {
25
+ document?: string;
26
+ mode?: "opt-in" | "primary";
27
+ profile?: "fences" | "modern" | "litpro";
28
+ adapter?: "asciidoc" | "html" | "markdown" | "markdown-litpro" | "myst" | "noweb" | "org";
29
+ dialect?: "litpro-2017" | "pieceful-2020" | "litpro-plus" | "noweb" | "noweb-plus";
30
+ references?: "noweb" | "org-noweb" | "underscore-quote" | "both";
31
+ language?: string;
32
+ nowebPipes?: boolean;
33
+ executionOwner?: "org" | "myst" | "ravel";
34
+ run?: boolean;
35
+ provider?: string;
36
+ overlays?: Map<string, string | { text: string; version?: number }> | Record<string, string | { text: string; version?: number }>;
37
+ /** Keep live resource declarations but do not read their contents. Intended for editor analysis. */
38
+ readLiveResources?: boolean;
39
+ signal?: AbortSignal;
40
+ }
7
41
  export class RavelInputError extends Error { diagnostics: Diagnostic[]; }
8
- export function loadPretransformGraph(entryPath: string, options?: { document?: string; mode?: "opt-in" | "primary" }): Promise<unknown>;
9
- export function loadTomlBuild(configPath: string): Promise<BuildInput>;
10
- export function loadBuildInput(inputPath: string, options?: { document?: string; mode?: "opt-in" | "primary" }): Promise<BuildInput>;
42
+ export function loadPretransformGraph(entryPath: string, options?: SourceAdapterOptions): Promise<unknown>;
43
+ export function loadTomlBuild(configPath: string, options?: SourceAdapterOptions): Promise<BuildInput>;
44
+ export function loadBuildInput(inputPath: string, options?: SourceAdapterOptions): Promise<BuildInput>;
11
45
  export function planDeliverables(program: RavelProgram, outputDirectory: string): { version: 1; outputDirectory: string; manifest: string; deliverables: Array<Record<string, unknown>> };
12
46
  export function planStaleDeliverables(program: RavelProgram, outputDirectory: string, options?: { rootDirectory?: string; staleSince?: string }): Promise<Array<Record<string, unknown>>>;
13
47
  export function writeDeliverables(program: RavelProgram, outputDirectory: string, options?: { rootDirectory?: string }): Promise<string[]>;
package/src/index.js CHANGED
@@ -8,11 +8,23 @@ import {
8
8
  createDeliverableProvenanceMap,
9
9
  provenanceMapVersion
10
10
  } from "@pieceful/ravel-core";
11
+ import { asciidocToMap } from "@pieceful/ravel-asciidoc";
12
+ import { htmlToMap } from "@pieceful/ravel-html";
11
13
  import { markdownToMap } from "@pieceful/ravel-markdown";
14
+ import { isLitproMarkdown, litproMarkdownToMap } from "@pieceful/ravel-markdown-litpro";
12
15
  import { assertRavelMap } from "@pieceful/ravel-map";
16
+ import { mystToMap } from "@pieceful/ravel-myst";
17
+ import { nowebToMap } from "@pieceful/ravel-noweb";
18
+ import { orgToMap } from "@pieceful/ravel-org";
13
19
 
14
20
  const missing = (error) => error?.code === "ENOENT";
15
21
 
22
+ const throwIfAborted = (signal) => {
23
+ if (!signal?.aborted) return;
24
+ if (signal.reason instanceof Error) throw signal.reason;
25
+ throw new DOMException("The Ravel input operation was cancelled.", "AbortError");
26
+ };
27
+
16
28
  const inputSource = (uri) => ({
17
29
  uri: typeof uri === "string" && uri.length ? uri : "<ravel-input>",
18
30
  range: {
@@ -37,7 +49,25 @@ const inputError = (code, message, uri) => new RavelInputError([{
37
49
  source: inputSource(uri)
38
50
  }]);
39
51
 
40
- const readInputText = async (path, description, code) => {
52
+ const overlayText = (overlays, path) => {
53
+ if (!overlays) return undefined;
54
+ const key = resolve(path);
55
+ const entry = overlays instanceof Map ? overlays.get(key) : overlays[key];
56
+ if (typeof entry === "string") return entry;
57
+ return typeof entry?.text === "string" ? entry.text : undefined;
58
+ };
59
+
60
+ const overlaysWithText = (overlays, path, text) => {
61
+ const captured = overlays instanceof Map
62
+ ? new Map(overlays)
63
+ : new Map(Object.entries(overlays ?? {}).map(([key, value]) => [resolve(key), value]));
64
+ captured.set(resolve(path), { text });
65
+ return captured;
66
+ };
67
+
68
+ const readInputText = async (path, description, code, overlays) => {
69
+ const overlay = overlayText(overlays, path);
70
+ if (overlay !== undefined) return overlay;
41
71
  try {
42
72
  return await readFile(path, "utf8");
43
73
  } catch (error) {
@@ -170,8 +200,8 @@ const createOutputScope = async (outputDirectory, rootDirectory) => {
170
200
  return { root: output, path: (path, description) => scopedPath(output, path, description) };
171
201
  };
172
202
 
173
- const readMap = async (path) => {
174
- const text = await readInputText(path, "Ravel Map", "RM201");
203
+ const readMap = async (path, overlays) => {
204
+ const text = await readInputText(path, "Ravel Map", "RM201", overlays);
175
205
  let map;
176
206
  try {
177
207
  map = JSON.parse(text);
@@ -181,56 +211,235 @@ const readMap = async (path) => {
181
211
  return assertRavelMap(map, { uri: path });
182
212
  };
183
213
 
184
- const loadMarkdownFile = async (path, options = {}) => markdownToMap(
185
- await readInputText(path, "Markdown input", "RM201"),
186
- { uri: path, document: options.document, mode: options.mode }
187
- );
214
+ const loadMarkdownFile = async (path, options = {}, overlays) => {
215
+ const text = await readInputText(path, "Markdown input", "RM201", overlays);
216
+ if (options.adapter === "markdown-litpro" || options.profile === "litpro" ||
217
+ (options.adapter === undefined && options.profile === undefined && isLitproMarkdown(text))) {
218
+ return litproMarkdownToMap(text, {
219
+ uri: path,
220
+ document: options.document,
221
+ dialect: options.dialect,
222
+ headings: options.headings
223
+ });
224
+ }
225
+ return markdownToMap(text, {
226
+ uri: path,
227
+ document: options.document,
228
+ mode: options.mode,
229
+ profile: options.profile
230
+ });
231
+ };
232
+
233
+ const loadNowebFile = async (path, options = {}, overlays) => {
234
+ const text = await readInputText(path, "noweb input", "RM201", overlays);
235
+ return nowebToMap(text, {
236
+ uri: path,
237
+ document: options.document,
238
+ dialect: options.dialect,
239
+ references: options.references,
240
+ language: options.language,
241
+ run: options.run,
242
+ provider: options.provider
243
+ });
244
+ };
245
+
246
+ const loadOrgFile = async (path, options = {}, overlays) => {
247
+ const text = await readInputText(path, "Org input", "RM201", overlays);
248
+ return orgToMap(text, {
249
+ uri: path,
250
+ document: options.document,
251
+ references: options.references,
252
+ nowebPipes: options.nowebPipes,
253
+ executionOwner: options.executionOwner,
254
+ run: options.run,
255
+ provider: options.provider
256
+ });
257
+ };
258
+
259
+ const loadMystFile = async (path, options = {}, overlays) => {
260
+ const text = await readInputText(path, "MyST input", "RM201", overlays);
261
+ return mystToMap(text, {
262
+ uri: path,
263
+ document: options.document,
264
+ executionOwner: options.executionOwner,
265
+ run: options.run,
266
+ provider: options.provider
267
+ });
268
+ };
269
+
270
+ const loadAsciiDocFile = async (path, options = {}, overlays) => {
271
+ const text = await readInputText(path, "AsciiDoc input", "RM201", overlays);
272
+ return asciidocToMap(text, {
273
+ uri: path,
274
+ document: options.document,
275
+ run: options.run,
276
+ provider: options.provider
277
+ });
278
+ };
279
+
280
+ const loadHtmlFile = async (path, options = {}, overlays) => {
281
+ const text = await readInputText(path, "HTML input", "RM201", overlays);
282
+ return htmlToMap(text, {
283
+ uri: path,
284
+ document: options.document,
285
+ run: options.run,
286
+ provider: options.provider
287
+ });
288
+ };
188
289
 
189
- const collectPretransformMaps = async (entryPath, entryOptions = {}, scope) => {
290
+ const collectPretransformMaps = async (
291
+ entryPath,
292
+ entryOptions = {},
293
+ scope,
294
+ overlays = entryOptions.overlays
295
+ ) => {
296
+ const signal = entryOptions.signal;
297
+ throwIfAborted(signal);
190
298
  const activeScope = scope ?? await createInputScope(dirname(resolve(entryPath)));
191
299
  const visited = new Set();
192
300
  const maps = [];
193
301
  const diagnostics = [];
302
+ const authoredSourcePaths = new Set();
303
+ const authoredSourceTexts = new Map();
304
+ let containsJsonMaps = false;
194
305
 
195
306
  const visit = async (path, options = {}) => {
307
+ throwIfAborted(signal);
196
308
  const absolutePath = await activeScope.path(path, "Ravel input");
197
309
  if (visited.has(absolutePath)) return;
198
310
  visited.add(absolutePath);
199
311
 
200
312
  const extension = extname(absolutePath).toLowerCase();
201
313
  let map;
314
+ let sourceOverlays = overlays;
315
+ let sourceText;
316
+ const mystExtension = absolutePath.toLowerCase().endsWith(".myst.md");
202
317
  if (extension === ".json") {
203
- map = await readMap(absolutePath);
204
- } else if (extension === ".md" || extension === ".markdown" || extension === ".mdown") {
205
- const result = await loadMarkdownFile(absolutePath, options);
318
+ containsJsonMaps = true;
319
+ map = await readMap(absolutePath, overlays);
320
+ } else if (mystExtension || options.adapter === "myst") {
321
+ authoredSourcePaths.add(absolutePath);
322
+ sourceText = await readInputText(absolutePath, "MyST input", "RM201", overlays);
323
+ sourceOverlays = overlaysWithText(overlays, absolutePath, sourceText);
324
+ const result = await loadMystFile(absolutePath, options, sourceOverlays);
325
+ map = result.map;
326
+ diagnostics.push(...result.diagnostics);
327
+ assertRavelMap(map, { uri: absolutePath });
328
+ } else if (extension === ".adoc" || extension === ".asciidoc" ||
329
+ options.adapter === "asciidoc") {
330
+ authoredSourcePaths.add(absolutePath);
331
+ sourceText = await readInputText(absolutePath, "AsciiDoc input", "RM201", overlays);
332
+ sourceOverlays = overlaysWithText(overlays, absolutePath, sourceText);
333
+ const result = await loadAsciiDocFile(absolutePath, options, sourceOverlays);
334
+ map = result.map;
335
+ diagnostics.push(...result.diagnostics);
336
+ assertRavelMap(map, { uri: absolutePath });
337
+ } else if (extension === ".html" || extension === ".htm" ||
338
+ options.adapter === "html") {
339
+ authoredSourcePaths.add(absolutePath);
340
+ sourceText = await readInputText(absolutePath, "HTML input", "RM201", overlays);
341
+ sourceOverlays = overlaysWithText(overlays, absolutePath, sourceText);
342
+ const result = await loadHtmlFile(absolutePath, options, sourceOverlays);
343
+ map = result.map;
344
+ diagnostics.push(...result.diagnostics);
345
+ assertRavelMap(map, { uri: absolutePath });
346
+ } else if (extension === ".org" || options.adapter === "org") {
347
+ authoredSourcePaths.add(absolutePath);
348
+ sourceText = await readInputText(absolutePath, "Org input", "RM201", overlays);
349
+ sourceOverlays = overlaysWithText(overlays, absolutePath, sourceText);
350
+ const result = await loadOrgFile(absolutePath, options, sourceOverlays);
351
+ map = result.map;
352
+ diagnostics.push(...result.diagnostics);
353
+ assertRavelMap(map, { uri: absolutePath });
354
+ } else if (extension === ".nw" || extension === ".noweb" || options.adapter === "noweb") {
355
+ authoredSourcePaths.add(absolutePath);
356
+ sourceText = await readInputText(absolutePath, "noweb input", "RM201", overlays);
357
+ sourceOverlays = overlaysWithText(overlays, absolutePath, sourceText);
358
+ const result = await loadNowebFile(absolutePath, options, sourceOverlays);
359
+ map = result.map;
360
+ diagnostics.push(...result.diagnostics);
361
+ assertRavelMap(map, { uri: absolutePath });
362
+ } else if (extension === ".md" || extension === ".markdown" || extension === ".mdown" || extension === ".qmd") {
363
+ authoredSourcePaths.add(absolutePath);
364
+ sourceText = await readInputText(absolutePath, "Markdown input", "RM201", overlays);
365
+ sourceOverlays = overlaysWithText(overlays, absolutePath, sourceText);
366
+ const result = await loadMarkdownFile(absolutePath, {
367
+ ...options,
368
+ profile: options.profile ?? (extension === ".qmd" ? "modern" : undefined)
369
+ }, sourceOverlays);
206
370
  map = result.map;
207
371
  diagnostics.push(...result.diagnostics);
208
372
  assertRavelMap(map, { uri: absolutePath });
209
373
  } else {
210
- throw inputError("RH101", "Ravel input must be a .json map or Markdown file.", absolutePath);
374
+ throw inputError("RH101", "Ravel input must be a .json map or supported markup source.", absolutePath);
211
375
  }
376
+ if (sourceText !== undefined) authoredSourceTexts.set(absolutePath, sourceText);
377
+ throwIfAborted(signal);
212
378
  for (const directive of map.directives ?? []) {
213
379
  if (directive.kind !== "in") continue;
214
380
  const target = directive.target ?? directive.name;
215
381
  if (typeof target !== "string" || !target) {
216
382
  throw inputError("RH102", "in directive requires a target path.", directive.source?.uri ?? absolutePath);
217
383
  }
218
- await visit(resolve(dirname(absolutePath), target));
384
+ const adapter = directive.metadata?.adapter;
385
+ const alias = directive.metadata?.legacy?.alias;
386
+ await visit(resolve(dirname(absolutePath), target), {
387
+ ...(adapter ? { adapter } : {}),
388
+ ...(directive.metadata?.dialect ? { dialect: directive.metadata.dialect } : {}),
389
+ ...(typeof alias === "string" && alias ? { document: alias } : {})
390
+ });
219
391
  }
220
392
  maps.push(map);
221
393
  };
222
394
 
223
395
  await visit(entryPath, entryOptions);
224
- return { maps, diagnostics, rootDirectory: activeScope.root };
396
+ throwIfAborted(signal);
397
+ return {
398
+ maps,
399
+ diagnostics,
400
+ rootDirectory: activeScope.root,
401
+ authoredSourcePaths: [...authoredSourcePaths].sort(),
402
+ authoredSourceTexts,
403
+ loadedInputPaths: [...visited].sort(),
404
+ containsJsonMaps
405
+ };
225
406
  };
226
407
 
227
- export const loadPretransformGraph = async (entryPath, options = {}) => {
228
- const collected = await collectPretransformMaps(resolve(entryPath), options);
408
+ const graphFromCollectedMaps = (collected) => {
229
409
  const graph = combineMaps(collected.maps);
230
410
  graph.diagnostics.push(...collected.diagnostics);
231
411
  return relativizeSourceUris(graph, collected.rootDirectory);
232
412
  };
233
413
 
414
+ const authoredSourceUris = (collected) => collected.authoredSourcePaths
415
+ .map((path) => relative(collected.rootDirectory, path).split(sep).join("/"))
416
+ .sort();
417
+
418
+ const loadedInputUris = (collected) => collected.loadedInputPaths
419
+ .map((path) => relative(collected.rootDirectory, path).split(sep).join("/"))
420
+ .sort();
421
+
422
+ const authoredSourceTexts = (collected) => Object.fromEntries(
423
+ [...collected.authoredSourceTexts.entries()]
424
+ .map(([path, text]) => [
425
+ relative(collected.rootDirectory, path).split(sep).join("/"),
426
+ text
427
+ ])
428
+ .sort(([left], [right]) => left.localeCompare(right))
429
+ );
430
+
431
+ export const loadPretransformGraph = async (entryPath, options = {}) => {
432
+ throwIfAborted(options.signal);
433
+ const collected = await collectPretransformMaps(
434
+ resolve(entryPath),
435
+ options,
436
+ undefined,
437
+ options.overlays
438
+ );
439
+ throwIfAborted(options.signal);
440
+ return graphFromCollectedMaps(collected);
441
+ };
442
+
234
443
  const configSource = inputSource;
235
444
 
236
445
  const requireConfigString = (value, description, configPath) => {
@@ -246,48 +455,213 @@ const reportUnknownKeys = (value, allowed, description, configPath) => {
246
455
  }
247
456
  };
248
457
 
249
- export const loadTomlBuild = async (configPath) => {
458
+ const loadLiveConfiguration = async (
459
+ live,
460
+ scope,
461
+ configPath,
462
+ overlays,
463
+ { readResources = true } = {}
464
+ ) => {
465
+ if (live === undefined) return undefined;
466
+ if (!live || typeof live !== "object" || Array.isArray(live)) {
467
+ throw inputError("RC102", "live must be a [live] table.", configPath);
468
+ }
469
+ reportUnknownKeys(live, new Set(["modules", "resources"]), "live", configPath);
470
+ if (live.modules !== undefined && !Array.isArray(live.modules)) {
471
+ throw inputError("RC102", "live.modules must contain [[live.modules]] entries.", configPath);
472
+ }
473
+ if (live.resources !== undefined && !Array.isArray(live.resources)) {
474
+ throw inputError("RC102", "live.resources must contain [[live.resources]] entries.", configPath);
475
+ }
476
+
477
+ const moduleSpecifiers = new Set();
478
+ const modules = (live.modules ?? []).map((entry, index) => {
479
+ const description = "live.modules[" + index + "]";
480
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
481
+ throw inputError("RC102", description + " must be a table.", configPath);
482
+ }
483
+ reportUnknownKeys(entry, new Set(["specifier", "from"]), description, configPath);
484
+ const specifier = requireConfigString(entry.specifier, description + ".specifier", configPath);
485
+ const from = requireConfigString(entry.from, description + ".from", configPath);
486
+ if (moduleSpecifiers.has(specifier)) {
487
+ throw inputError("RC102", "Duplicate live module specifier: " + specifier, configPath);
488
+ }
489
+ moduleSpecifiers.add(specifier);
490
+ return { specifier, from, source: configSource(configPath) };
491
+ });
492
+
493
+ const resourceNames = new Set();
494
+ const resourceResults = await Promise.all((live.resources ?? []).map(async (entry, index) => {
495
+ const description = "live.resources[" + index + "]";
496
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
497
+ throw inputError("RC102", description + " must be a table.", configPath);
498
+ }
499
+ reportUnknownKeys(entry, new Set(["name", "path"]), description, configPath);
500
+ const name = requireConfigString(entry.name, description + ".name", configPath);
501
+ const path = requireConfigString(entry.path, description + ".path", configPath);
502
+ if (resourceNames.has(name)) {
503
+ throw inputError("RC102", "Duplicate live resource name: " + name, configPath);
504
+ }
505
+ resourceNames.add(name);
506
+ const absolutePath = await scope.path(path, description + ".path");
507
+ const declaration = { name, path, source: configSource(configPath) };
508
+ if (!readResources) return { declaration };
509
+ return {
510
+ declaration,
511
+ entry: [name, await readInputText(
512
+ absolutePath,
513
+ "live resource",
514
+ "RC103",
515
+ overlays
516
+ )]
517
+ };
518
+ }));
519
+
520
+ return {
521
+ modules,
522
+ resources: Object.fromEntries(resourceResults.flatMap((result) =>
523
+ result.entry ? [result.entry] : []
524
+ )),
525
+ resourceDeclarations: resourceResults.map(({ declaration }) => declaration)
526
+ };
527
+ };
528
+
529
+ export const loadTomlBuild = async (configPath, options = {}) => {
530
+ throwIfAborted(options.signal);
250
531
  const absoluteConfig = resolve(configPath);
251
532
  const scope = await createInputScope(dirname(absoluteConfig));
252
533
  const configFile = await scope.path(absoluteConfig, "Ravel TOML config");
253
534
  let config;
254
535
  try {
255
- config = parseToml(await readInputText(configFile, "Ravel TOML config", "RC101"));
536
+ config = parseToml(await readInputText(
537
+ configFile,
538
+ "Ravel TOML config",
539
+ "RC101",
540
+ options.overlays
541
+ ));
542
+ throwIfAborted(options.signal);
256
543
  } catch (error) {
257
544
  if (Array.isArray(error?.diagnostics)) throw error;
258
545
  throw inputError("RC101", "Invalid Ravel TOML config: " + (error?.message ?? String(error)), absoluteConfig);
259
546
  }
260
- reportUnknownKeys(config, new Set(["version", "files", "build", "outputs"]), "config", absoluteConfig);
547
+ reportUnknownKeys(config, new Set(["version", "files", "build", "outputs", "live"]), "config", absoluteConfig);
261
548
  if (config.version !== 1) throw inputError("RC102", "version must be 1.", absoluteConfig);
262
549
  if (!Array.isArray(config.files) || config.files.length === 0) {
263
550
  throw inputError("RC102", "files must contain one or more [[files]] entries.", absoluteConfig);
264
551
  }
265
- if (!config.build || typeof config.build !== "object") {
552
+ if (config.build !== undefined && (!config.build || typeof config.build !== "object" || Array.isArray(config.build))) {
266
553
  throw inputError("RC102", "build must be a [build] table.", absoluteConfig);
267
554
  }
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") {
555
+ const build = config.build ?? {};
556
+ reportUnknownKeys(build, new Set(["name", "out_dir", "clean", "backup"]), "build", absoluteConfig);
557
+ if (build.name !== undefined) requireConfigString(build.name, "build.name", absoluteConfig);
558
+ if (build.clean !== undefined && typeof build.clean !== "boolean") {
271
559
  throw inputError("RC102", "build.clean must be true or false.", absoluteConfig);
272
560
  }
273
- if (config.build.backup !== undefined && typeof config.build.backup !== "boolean" &&
274
- (typeof config.build.backup !== "string" || config.build.backup.length === 0)) {
561
+ if (build.backup !== undefined && typeof build.backup !== "boolean" &&
562
+ (typeof build.backup !== "string" || build.backup.length === 0)) {
275
563
  throw inputError("RC102", "build.backup must be true, false, or a non-empty .zip path.", absoluteConfig);
276
564
  }
277
- if (typeof config.build.backup === "string" && extname(config.build.backup).toLowerCase() !== ".zip") {
565
+ if (typeof build.backup === "string" && extname(build.backup).toLowerCase() !== ".zip") {
278
566
  throw inputError("RC102", "build.backup must name a .zip file.", absoluteConfig);
279
567
  }
280
- const outDirectory = requireConfigString(config.build.out_dir, "build.out_dir", absoluteConfig);
568
+ const outDirectory = build.out_dir === undefined
569
+ ? undefined
570
+ : requireConfigString(build.out_dir, "build.out_dir", absoluteConfig);
281
571
  const baseDirectory = scope.root;
572
+ const live = await loadLiveConfiguration(
573
+ config.live,
574
+ scope,
575
+ absoluteConfig,
576
+ options.overlays,
577
+ { readResources: options.readLiveResources !== false }
578
+ );
282
579
  const results = await Promise.all(config.files.map(async (file, index) => {
283
580
  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);
581
+ reportUnknownKeys(file, new Set([
582
+ "path", "document", "mode", "profile", "adapter", "dialect",
583
+ "references", "language", "noweb_pipes", "execution_owner", "run", "provider"
584
+ ]), "files[" + index + "]", absoluteConfig);
285
585
  const path = requireConfigString(file.path, "files[" + index + "].path", absoluteConfig);
286
586
  if (file.document !== undefined) requireConfigString(file.document, "files[" + index + "].document", absoluteConfig);
287
587
  const mode = file.mode ?? "opt-in";
288
588
  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);
589
+ const profile = file.profile;
590
+ if (profile !== undefined && !["fences", "modern", "litpro"].includes(profile)) {
591
+ throw inputError("RC102", "files[" + index + "].profile must be fences, modern, or litpro.", absoluteConfig);
592
+ }
593
+ const adapter = file.adapter;
594
+ if (adapter !== undefined && !["asciidoc", "html", "markdown", "markdown-litpro", "myst", "noweb", "org"].includes(adapter)) {
595
+ throw inputError("RC102", "files[" + index + "].adapter must be asciidoc, html, markdown, markdown-litpro, myst, noweb, or org.", absoluteConfig);
596
+ }
597
+ const dialect = file.dialect;
598
+ const supportedDialects = adapter === "noweb"
599
+ ? ["noweb", "noweb-plus"]
600
+ : adapter === "asciidoc" || adapter === "html" ||
601
+ adapter === "org" || adapter === "myst"
602
+ ? []
603
+ : ["litpro-2017", "pieceful-2020", "litpro-plus"];
604
+ if (dialect !== undefined && !supportedDialects.includes(dialect)) {
605
+ throw inputError("RC102", "files[" + index + "].dialect is not supported by its adapter.", absoluteConfig);
606
+ }
607
+ const references = file.references;
608
+ const supportedReferences = adapter === "org"
609
+ ? ["org-noweb", "underscore-quote", "both"]
610
+ : ["noweb", "underscore-quote", "both"];
611
+ if (references !== undefined && !supportedReferences.includes(references)) {
612
+ throw inputError("RC102", "files[" + index + "].references is not supported by its adapter.", absoluteConfig);
613
+ }
614
+ if (file.language !== undefined) requireConfigString(file.language, "files[" + index + "].language", absoluteConfig);
615
+ if (file.noweb_pipes !== undefined && typeof file.noweb_pipes !== "boolean") {
616
+ throw inputError("RC102", "files[" + index + "].noweb_pipes must be true or false.", absoluteConfig);
617
+ }
618
+ const executionOwner = file.execution_owner;
619
+ const supportedExecutionOwners = adapter === "myst"
620
+ ? ["myst", "ravel"]
621
+ : ["org", "ravel"];
622
+ if (executionOwner !== undefined && !supportedExecutionOwners.includes(executionOwner)) {
623
+ throw inputError(
624
+ "RC102",
625
+ "files[" + index + "].execution_owner is not supported by its adapter.",
626
+ absoluteConfig
627
+ );
628
+ }
629
+ if (file.run !== undefined && typeof file.run !== "boolean") {
630
+ throw inputError("RC102", "files[" + index + "].run must be true or false.", absoluteConfig);
631
+ }
632
+ if (file.provider !== undefined) requireConfigString(file.provider, "files[" + index + "].provider", absoluteConfig);
633
+ if (file.language !== undefined && adapter !== "noweb") {
634
+ throw inputError("RC102", "files[" + index + "].language currently requires adapter = \"noweb\".", absoluteConfig);
635
+ }
636
+ if (file.noweb_pipes !== undefined && adapter !== "org") {
637
+ throw inputError("RC102", "noweb_pipes requires adapter = \"org\".", absoluteConfig);
638
+ }
639
+ if (executionOwner !== undefined && !["org", "myst"].includes(adapter)) {
640
+ throw inputError("RC102", "execution_owner requires adapter = \"org\" or \"myst\".", absoluteConfig);
641
+ }
642
+ if (references !== undefined && !["noweb", "org"].includes(adapter)) {
643
+ throw inputError("RC102", "references requires adapter = \"noweb\" or \"org\".", absoluteConfig);
644
+ }
645
+ if ((file.run !== undefined || file.provider !== undefined) &&
646
+ !["asciidoc", "html", "noweb", "org", "myst"].includes(adapter)) {
647
+ throw inputError("RC102", "run and provider settings require adapter = \"asciidoc\", \"html\", \"noweb\", \"org\", or \"myst\".", absoluteConfig);
648
+ }
649
+ return collectPretransformMaps(resolve(baseDirectory, path), {
650
+ document: file.document,
651
+ mode,
652
+ profile,
653
+ adapter,
654
+ dialect,
655
+ references,
656
+ language: file.language,
657
+ nowebPipes: file.noweb_pipes,
658
+ executionOwner,
659
+ run: file.run,
660
+ provider: file.provider,
661
+ signal: options.signal
662
+ }, scope, options.overlays);
290
663
  }));
664
+ throwIfAborted(options.signal);
291
665
  const pretransform = combineMaps(results.flatMap((result) => result.maps));
292
666
  pretransform.diagnostics.push(...results.flatMap((result) => result.diagnostics));
293
667
  for (const output of config.outputs ?? []) {
@@ -302,35 +676,50 @@ export const loadTomlBuild = async (configPath) => {
302
676
  }
303
677
  return {
304
678
  pretransform: relativizeSourceUris(pretransform, scope.root),
305
- outputDirectory: await scope.path(outDirectory, "build.out_dir"),
679
+ authoredSourceUris: [...new Set(results.flatMap(authoredSourceUris))].sort(),
680
+ authoredSourceTexts: Object.assign({}, ...results.map(authoredSourceTexts)),
681
+ loadedInputUris: [...new Set([
682
+ relative(scope.root, absoluteConfig).split(sep).join("/"),
683
+ ...results.flatMap(loadedInputUris)
684
+ ])].sort(),
685
+ sourceEditsAllowed: results.every((result) => !result.containsJsonMaps),
686
+ ...(outDirectory === undefined ? {} : { outputDirectory: await scope.path(outDirectory, "build.out_dir") }),
306
687
  rootDirectory: scope.root,
307
688
  buildOptions: {
308
- clean: config.build.clean === true,
309
- backup: config.build.backup ?? false
310
- }
689
+ clean: build.clean === true,
690
+ backup: build.backup ?? false
691
+ },
692
+ ...(live ? { live } : {})
311
693
  };
312
694
  };
313
695
 
314
- /** Load a JSON map, one Markdown document, or a single Ravel TOML build run. */
696
+ /** Load a JSON map, one markup source document, or a Ravel TOML build run. */
315
697
  export const loadBuildInput = async (inputPath, options = {}) => {
698
+ throwIfAborted(options.signal);
316
699
  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") {
700
+ if (extension === ".toml") return loadTomlBuild(inputPath, options);
701
+ if (extension === ".md" || extension === ".markdown" || extension === ".mdown" || extension === ".qmd" ||
702
+ extension === ".nw" || extension === ".noweb" || extension === ".org" ||
703
+ extension === ".adoc" || extension === ".asciidoc" ||
704
+ extension === ".html" || extension === ".htm" || extension === ".json") {
705
+ const collected = await collectPretransformMaps(
706
+ resolve(inputPath),
707
+ options,
708
+ undefined,
709
+ options.overlays
710
+ );
711
+ throwIfAborted(options.signal);
327
712
  return {
328
- pretransform: await loadPretransformGraph(inputPath),
713
+ pretransform: graphFromCollectedMaps(collected),
329
714
  outputDirectory: undefined,
330
- rootDirectory: dirname(resolve(inputPath))
715
+ rootDirectory: collected.rootDirectory,
716
+ authoredSourceUris: authoredSourceUris(collected),
717
+ authoredSourceTexts: authoredSourceTexts(collected),
718
+ loadedInputUris: loadedInputUris(collected),
719
+ sourceEditsAllowed: !collected.containsJsonMaps
331
720
  };
332
721
  }
333
- throw inputError("RH101", "Ravel input must be a .json map, Markdown document, or .toml build config.", inputPath);
722
+ throw inputError("RH101", "Ravel input must be a .json map, supported markup document, or .toml build config.", inputPath);
334
723
  };
335
724
 
336
725
  const safeDestination = (outputDirectory, name) => {