@weborigami/origami 0.6.15 → 0.6.17

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.
@@ -1,12 +1,9 @@
1
- import { OrigamiFileMap } from "@weborigami/language";
2
1
  import { fork } from "node:child_process";
3
2
  import { EventEmitter } from "node:events";
4
3
  import http from "node:http";
5
- import net from "node:net";
6
- import path from "node:path";
4
+ import { findOpenPort } from "../../common/findOpenPort.js";
7
5
 
8
6
  const PUBLIC_HOST = "127.0.0.1";
9
- const DEFAULT_PORT = 5000;
10
7
 
11
8
  // Module that loads the server in the child process
12
9
  const childModuleUrl = new URL("./debugChild.js", import.meta.url);
@@ -15,9 +12,6 @@ const childModuleUrl = new URL("./debugChild.js", import.meta.url);
15
12
  let publicServer;
16
13
  let publicOrigin;
17
14
 
18
- // The tree of files in the parent path, which we watch for changes
19
- let tree;
20
-
21
15
  // The active child process and port
22
16
  /** @typedef {import("node:child_process").ChildProcess} ChildProcess */
23
17
  /** @typedef {{ process: ChildProcess, port: number | null }} ChildInfo */
@@ -44,9 +38,6 @@ let emitter = null;
44
38
  * whenever files in the parent tree change.
45
39
  *
46
40
  * Supported `options`:
47
- * - `debugFilesPath`: path to resources that will be added to the served tree
48
- * - `enableUnsafeEval`: if true, enables the `!eval` debug command in the child
49
- * process; default is false
50
41
  * - `expression` (required): the Origami expression to evaluate in the child
51
42
  * process
52
43
  * - `parentPath` (required): the path to the parent tree used for evaluation
@@ -55,47 +46,32 @@ let emitter = null;
55
46
  * child server encounters an Origami error while handling a request.
56
47
  *
57
48
  * @param {Object} options
58
- * @param {string} options.debugFilesPath
59
- * @param {boolean} options.enableUnsafeEval
60
49
  * @param {string} options.expression
61
50
  * @param {string} options.parentPath
51
+ * @param {number} [options.port]
52
+ * @param {boolean} [options.quiet]
62
53
  */
63
54
  export default async function debugParent(options) {
64
55
  const { parentPath } = options;
56
+ if (parentPath === undefined) {
57
+ throw new Error("Debugger couldn't work out the parent path.");
58
+ }
65
59
 
66
- tree = new OrigamiFileMap(parentPath);
67
- tree.watch();
68
- tree.addEventListener?.("change", (event) => {
69
- // @ts-ignore
70
- const { filePath } = event.options;
71
- if (isJavaScriptFile(filePath)) {
72
- // Need to restart the child process
73
- console.log("JavaScript file changed, restarting server…");
74
- startChild(options);
75
- } else if (isPackageJsonFile(filePath)) {
76
- // Need to restart the child process
77
- console.log("package.json changed, restarting server…");
78
- startChild(options);
79
- } else {
80
- // Just have the child reevaluate the expression
81
- console.log("File changed, reloading site…");
82
- activeChild?.process.send({ type: "REEVALUATE" });
83
- }
84
- });
85
-
86
- const port = await findOpenPort();
60
+ const port = options.port ?? (await findOpenPort());
87
61
  publicOrigin = `http://${PUBLIC_HOST}:${port}`;
88
62
 
89
63
  publicServer = http.createServer(proxyRequest);
90
- publicServer.listen(port, PUBLIC_HOST, () => {
91
- startChild(options);
92
- console.log(`Server running at ${publicOrigin}. Press Ctrl+C to stop.`);
93
- });
64
+ await new Promise((resolve) =>
65
+ publicServer.listen(port, PUBLIC_HOST, resolve),
66
+ );
67
+ await startChild(options);
94
68
 
95
69
  emitter = Object.assign(new EventEmitter(), {
96
70
  close,
71
+ origin: publicOrigin,
72
+ reevaluate,
73
+ restart: () => startChild(options),
97
74
  });
98
-
99
75
  return emitter;
100
76
  }
101
77
 
@@ -110,7 +86,6 @@ async function close() {
110
86
  publicServer.closeAllConnections();
111
87
  await closed;
112
88
  publicServer = null;
113
- emitter = null;
114
89
 
115
90
  // Drain and stop any children concurrently
116
91
  const children = [pendingChild?.process, activeChild?.process].filter(
@@ -121,9 +96,9 @@ async function close() {
121
96
  activeChild = null;
122
97
  await Promise.all(children.map(drainAndStopChild));
123
98
 
124
- // Stop watching for file changes
125
- tree.unwatch();
126
- tree = null;
99
+ emitter.emit("close");
100
+ emitter.removeAllListeners();
101
+ emitter = null;
127
102
  }
128
103
 
129
104
  /**
@@ -182,66 +157,6 @@ async function drainAndStopChild(childProcess) {
182
157
  }, GRACE_MS).unref();
183
158
  }
184
159
 
185
- // Return the first open port number on or after the given port number.
186
- async function findOpenPort(startPort = DEFAULT_PORT) {
187
- for (let port = startPort; port <= 65535; port++) {
188
- if (await isPortAvailable(port)) {
189
- return port;
190
- }
191
- }
192
-
193
- throw new Error(`No open port found on or after ${startPort}`);
194
- }
195
-
196
- function isJavaScriptFile(filePath) {
197
- const extname = path.extname(filePath).toLowerCase();
198
- const jsExtensions = [".cjs", ".js", ".mjs", ".ts"];
199
- return jsExtensions.includes(extname);
200
- }
201
-
202
- function isPackageJsonFile(filePath) {
203
- return path.basename(filePath).toLowerCase() === "package.json";
204
- }
205
-
206
- /**
207
- * Check whether a port is available on both IPv4 and IPv6 loopback addresses
208
- * by attempting TCP connections. On macOS, IPv4 and IPv6 port spaces are
209
- * independent (IPV6_V6ONLY=1 by default), so a server bound to [::]:PORT is
210
- * invisible to a 127.0.0.1 bind check. Using connect probes on both loopbacks
211
- * catches servers regardless of which protocol family they listen on. Any
212
- * connection error (ECONNREFUSED, EADDRNOTAVAIL, etc.) means nothing is
213
- * listening there, so the function is safe on systems without IPv6.
214
- *
215
- * @param {number} port
216
- * @returns {Promise<boolean>}
217
- */
218
- async function isPortAvailable(port) {
219
- const [v4, v6] = await Promise.all([
220
- isPortListening("127.0.0.1", port),
221
- isPortListening("::1", port),
222
- ]);
223
- return !v4 && !v6;
224
- }
225
-
226
- /**
227
- * @param {string} host
228
- * @param {number} port
229
- * @returns {Promise<boolean>}
230
- */
231
- function isPortListening(host, port) {
232
- return new Promise((resolve) => {
233
- const socket = net.createConnection(port, host);
234
- socket.once("connect", () => {
235
- socket.destroy();
236
- resolve(true);
237
- });
238
- socket.once("error", () => {
239
- socket.destroy();
240
- resolve(false);
241
- });
242
- });
243
- }
244
-
245
160
  /**
246
161
  * Proxy incoming requests to the active child server, or return a 503 if not
247
162
  * ready.
@@ -322,6 +237,30 @@ function proxyRequest(request, response) {
322
237
  request.pipe(upstreamRequest);
323
238
  }
324
239
 
240
+ async function reevaluate() {
241
+ if (!activeChild) {
242
+ return;
243
+ }
244
+
245
+ const child = activeChild;
246
+
247
+ // Wait for the next EVALUATED message from the child
248
+ const evaluated = /** @type {Promise<void>} */ (
249
+ new Promise((resolve) => {
250
+ const onMessage = (/** @type {any} */ msg) => {
251
+ if (msg && typeof msg === "object" && msg.type === "EVALUATED") {
252
+ child.process.off("message", onMessage);
253
+ resolve();
254
+ }
255
+ };
256
+ child.process.on("message", onMessage);
257
+ })
258
+ );
259
+
260
+ child.process.send({ type: "REEVALUATE" });
261
+ await evaluated;
262
+ }
263
+
325
264
  /**
326
265
  * Start a new child process.
327
266
  *
@@ -329,7 +268,8 @@ function proxyRequest(request, response) {
329
268
  * it becomes active and any previous active child is drained and stopped.
330
269
  */
331
270
  function startChild(options) {
332
- const { debugFilesPath, enableUnsafeEval, expression, parentPath } = options;
271
+ const { expression, parentPath } = options;
272
+ const quiet = options.quiet ?? false;
333
273
 
334
274
  // Start the child process, passing parent path via an environment variable.
335
275
  /** @type {ChildProcess} */
@@ -339,10 +279,9 @@ function startChild(options) {
339
279
  stdio: ["inherit", "inherit", "inherit", "ipc"],
340
280
  env: {
341
281
  ...process.env,
342
- ORIGAMI_DEBUG_FILES_PATH: debugFilesPath,
343
- ORIGAMI_ENABLE_UNSAFE_EVAL: enableUnsafeEval ? "1" : "0",
344
282
  ORIGAMI_EXPRESSION: expression,
345
283
  ORIGAMI_PARENT_PATH: parentPath,
284
+ ORIGAMI_QUIET: quiet ? "1" : "0",
346
285
  },
347
286
  });
348
287
  } catch (error) {
@@ -354,61 +293,77 @@ function startChild(options) {
354
293
  // This becomes the pending child immediately
355
294
  pendingChild = { process: childProcess, port: null };
356
295
 
357
- // Listen for messages from the child about its status
358
- childProcess.on("message", (/** @type {any} */ message) => {
359
- if (!message || typeof message !== "object") {
360
- return;
361
- }
362
-
363
- if (message.type === "READY" && typeof message.port === "number") {
364
- // Only promote to active if this is still the pending child
365
- if (pendingChild?.process === childProcess) {
366
- const previousChild = activeChild;
367
-
368
- activeChild = pendingChild;
369
- pendingChild.port = message.port;
370
- pendingChild = null;
371
-
372
- // Drain previous child in background (don't wait)
373
- if (previousChild?.process && previousChild.process !== childProcess) {
374
- drainAndStopChild(previousChild.process).catch((err) =>
375
- console.error("[drain]", err),
376
- );
296
+ // Returns a Promise that resolves when the child signals READY, or rejects
297
+ // on FATAL or unexpected exit before ready.
298
+ return /** @type {Promise<void>} */ (
299
+ new Promise((resolve, reject) => {
300
+ // Listen for messages from the child about its status
301
+ childProcess.on("message", (/** @type {any} */ message) => {
302
+ if (!message || typeof message !== "object") {
303
+ return;
304
+ } else if (
305
+ message.type === "READY" &&
306
+ typeof message.port === "number"
307
+ ) {
308
+ // Only promote to active if this is still the pending child
309
+ if (pendingChild?.process === childProcess) {
310
+ const previousChild = activeChild;
311
+
312
+ activeChild = pendingChild;
313
+ pendingChild.port = message.port;
314
+ pendingChild = null;
315
+
316
+ // Drain previous child in background (don't wait)
317
+ if (
318
+ previousChild?.process &&
319
+ previousChild.process !== childProcess
320
+ ) {
321
+ drainAndStopChild(previousChild.process).catch((err) =>
322
+ console.error("[drain]", err),
323
+ );
324
+ }
325
+
326
+ if (emitter) {
327
+ emitter.emit("ready", { origin: publicOrigin });
328
+ }
329
+ resolve();
330
+ } else {
331
+ // This child was superseded by a newer one, kill it
332
+ // console.log("Child process superseded by newer one, killing it...");
333
+ childProcess.kill("SIGTERM");
334
+ }
335
+ } else if (message.type === "EVALUATED") {
336
+ // Let caller know child has reevaluated the expression (after a file change)
337
+ if (emitter) {
338
+ emitter.emit("evaluated");
339
+ }
340
+ } else if (message.type === "FATAL") {
341
+ // Child couldn't start (import error, etc.)
342
+ // Keep previous active child if any; otherwise we'll serve 500/503.
343
+ console.error("[child fatal]", message.error ?? message);
344
+ if (pendingChild?.process === childProcess) {
345
+ pendingChild = null;
346
+ }
347
+ reject(new Error(message.error ?? "Child server failed to start"));
377
348
  }
349
+ });
378
350
 
379
- if (!emitter) {
380
- console.warn(
381
- "Dev.debug2: child server is ready but parent emitter is gone, cannot emit ready event",
351
+ childProcess.on("exit", (code, signal) => {
352
+ if (activeChild?.process === childProcess) {
353
+ // Active child died unexpectedly.
354
+ activeChild = null;
355
+ }
356
+ if (pendingChild?.process === childProcess) {
357
+ pendingChild = null;
358
+ // Child died before it was ready. If child was ready and resolve()
359
+ // was called, when the child exits, then reject() is a no-op.
360
+ reject(
361
+ new Error(
362
+ `Child exited before ready (code=${code}, signal=${signal})`,
363
+ ),
382
364
  );
383
- return;
384
365
  }
385
- emitter.emit("ready", {
386
- origin: publicOrigin,
387
- });
388
- } else {
389
- // This child was superseded by a newer one, kill it
390
- // console.log("Child process superseded by newer one, killing it...");
391
- childProcess.kill("SIGTERM");
392
- }
393
- }
394
-
395
- if (message.type === "FATAL") {
396
- // Child couldn't start (import error, etc.)
397
- // Keep previous active child if any; otherwise we'll serve 500/503.
398
- console.error("[child fatal]", message.error ?? message);
399
- if (pendingChild?.process === childProcess) {
400
- pendingChild = null;
401
- }
402
- }
403
- });
404
-
405
- childProcess.on("exit", (code, signal) => {
406
- if (activeChild?.process === childProcess) {
407
- // Active child died unexpectedly.
408
- activeChild = null;
409
- }
410
- if (pendingChild?.process === childProcess) {
411
- pendingChild = null;
412
- }
413
- });
366
+ });
367
+ })
368
+ );
414
369
  }
@@ -9,10 +9,9 @@ import {
9
9
  scope,
10
10
  trailingSlash,
11
11
  } from "@weborigami/async-tree";
12
- import { OrigamiFileMap } from "@weborigami/language";
13
12
  import indexPage from "../../origami/indexPage.js";
14
13
  import yaml from "../../origami/yaml.js";
15
- import debugCommands from "./debugCommands.js";
14
+ import * as debugCommands from "./debugCommands.js";
16
15
 
17
16
  /**
18
17
  * Transform the given map-based tree to add debugging resources:
@@ -23,19 +22,29 @@ import debugCommands from "./debugCommands.js";
23
22
  *
24
23
  * Also transform a simple object result to YAML for viewing.
25
24
  *
26
- * @param {import("@weborigami/async-tree").Maplike} maplike
27
- * @param {string} debugFilesPath
28
- * @param {boolean} enableUnsafeEval
25
+ * @typedef {import("@weborigami/async-tree").Maplike} Maplike
26
+ * @typedef {import("@weborigami/async-tree").Packed} Packed
27
+ *
28
+ * @param {Maplike|Packed} input
29
29
  */
30
- export default function debugTransform(
31
- maplike,
32
- debugFilesPath = "",
33
- enableUnsafeEval = false,
34
- ) {
35
- const source = Tree.from(maplike, { deep: true });
36
- const commands = debugCommands(enableUnsafeEval);
30
+ export default function debugTransform(input) {
31
+ if (isUnpackable(input)) {
32
+ // If the value isn't a tree, but has a tree attached via an `unpack`
33
+ // method, destructively wrap the unpack method to add this transform.
34
+ const original = input.unpack.bind(input);
35
+ input.unpack = async () => {
36
+ const content = await original();
37
+ if (!Tree.isTraversable(content) || typeof content === "function") {
38
+ return content;
39
+ }
40
+ /** @type {any} */
41
+ let tree = Tree.from(content);
42
+ return debugTransform(tree);
43
+ };
44
+ return input;
45
+ }
37
46
 
38
- const debugFiles = debugFilesPath ? new OrigamiFileMap(debugFilesPath) : null;
47
+ const source = Tree.from(input, { deep: true });
39
48
 
40
49
  return Object.assign(new AsyncMap(), {
41
50
  description: "debug resources",
@@ -44,22 +53,15 @@ export default function debugTransform(
44
53
  // Ask the tree if it has the key.
45
54
  let value = await source.get(key);
46
55
 
47
- if (value === undefined && debugFiles) {
48
- // Try the debug files
49
- value = await debugFiles.get(key);
50
- }
51
-
52
56
  if (value === undefined) {
53
57
  // Try the defaults and commands
54
- if (key === "_debugger/") {
55
- return debugFiles;
56
- } else if (key === "index.html") {
58
+ if (key === "index.html") {
57
59
  // Generate an index page for this site
58
60
  value = await indexPage(source);
59
61
  } else if (key === ".keys.json") {
60
62
  value = await jsonKeys.stringify(source);
61
63
  } else if (typeof key === "string" && key.startsWith("!")) {
62
- value = await invokeOrigamiCommand(commands, source, key);
64
+ value = await invokeOrigamiCommand(source, key);
63
65
  }
64
66
  }
65
67
 
@@ -72,29 +74,21 @@ export default function debugTransform(
72
74
  Tree.merge(object, {
73
75
  "index.html": yamlText,
74
76
  });
75
- } else if (Tree.isMaplike(value) && !Tree.isMap(value)) {
77
+ } else if (
78
+ Tree.isMaplike(value) &&
79
+ !Tree.isMap(value) &&
80
+ typeof value !== "function"
81
+ ) {
76
82
  // Make it a map so we can debug it
77
83
  value = Tree.from(value);
78
84
  }
79
85
 
80
- if (Tree.isMap(value)) {
81
- // Ensure this transform is applied to any map result.
82
- // Note: debugFilesPath only needed at top level.
83
- value = debugTransform(value, "", enableUnsafeEval);
84
- } else if (value?.unpack) {
85
- // If the value isn't a tree, but has a tree attached via an `unpack`
86
- // method, wrap the unpack method to add this transform.
87
- const original = value.unpack.bind(value);
88
- value.unpack = async () => {
89
- const content = await original();
90
- if (!Tree.isTraversable(content) || typeof content === "function") {
91
- return content;
92
- }
93
- /** @type {any} */
94
- let tree = Tree.from(content);
95
- return debugTransform(tree);
96
- };
86
+ // Ensure this transform is applied to any map result, or any object with
87
+ // an unpack method that returns a map.
88
+ if (Tree.isMap(value) || value?.unpack) {
89
+ value = debugTransform(value);
97
90
  }
91
+
98
92
  return value;
99
93
  },
100
94
 
@@ -114,15 +108,15 @@ export default function debugTransform(
114
108
  });
115
109
  }
116
110
 
117
- async function invokeOrigamiCommand(commands, tree, key) {
111
+ async function invokeOrigamiCommand(tree, key) {
118
112
  // Key is an Origami command; invoke it.
119
113
  const commandName = trailingSlash.remove(key.slice(1).trim());
120
114
 
121
115
  // Look for the indicated command
122
- const command = commands[commandName];
116
+ const command = debugCommands[commandName];
123
117
  let value;
124
118
  if (command) {
125
- value = await command(tree);
119
+ value = command instanceof Function ? await command(tree) : command;
126
120
  } else {
127
121
  // Look for command in scope
128
122
  const parentScope = await scope(tree);
@@ -15,13 +15,11 @@ let version = 0;
15
15
  * resource tree, then transform that tree with debug resources and return it.
16
16
  *
17
17
  * @param {Object} options
18
- * @param {string} options.debugFilesPath
19
- * @param {boolean} options.enableUnsafeEval
20
18
  * @param {string} options.expression
21
19
  * @param {string} options.parentPath
22
20
  */
23
21
  export default async function expressionTree(options) {
24
- const { debugFilesPath, expression, parentPath, enableUnsafeEval } = options;
22
+ const { expression, parentPath } = options;
25
23
 
26
24
  const parent = new OrigamiFileMap(parentPath);
27
25
  const globals = await projectGlobals(parent);
@@ -47,7 +45,7 @@ export default async function expressionTree(options) {
47
45
  setParent(maplike, parent);
48
46
 
49
47
  // Add debugging resources
50
- const tree = debugTransform(maplike, debugFilesPath, enableUnsafeEval);
48
+ const tree = debugTransform(maplike);
51
49
 
52
50
  /** @type {any} */ (tree).version = version++;
53
51
 
@@ -1,17 +1,49 @@
1
- import ori from "../../origami/ori.js";
1
+ import { trailingSlash, Tree } from "@weborigami/async-tree";
2
+ import { evaluate, projectGlobals } from "@weborigami/language";
3
+ import debugTransform from "./debugTransform.js";
2
4
 
3
- let lastExpression;
4
- let lastResult;
5
+ const mapParentToResult = new WeakMap();
5
6
 
7
+ /**
8
+ * Return a route that evaluates a key containing an expression.
9
+ *
10
+ * Because this route may be used multiple times to return the resources for a
11
+ * page, we cache the result for each parent and key.
12
+ *
13
+ * To allow multiple evaluations of the same expression, we expect the key to be
14
+ * of the form `<counter>,<expression>`, where the counter is a number that will
15
+ * be used for caching but ignored for evaluation. The debugger increments the
16
+ * counter to force reevaluation of the same expression.
17
+ */
6
18
  export default async function oriEval(parent) {
7
- return async (encodedExpression) => {
8
- console.log(encodedExpression);
9
- const expression = decodeURIComponent(encodedExpression);
10
- if (expression === lastExpression) {
11
- return lastResult;
19
+ const globals = await projectGlobals(parent);
20
+ return async (key) => {
21
+ const normalizedKey = trailingSlash.remove(key);
22
+ let result = mapParentToResult.get(parent);
23
+ if (result?.key === normalizedKey) {
24
+ return result.value;
12
25
  }
13
- lastExpression = expression;
14
- lastResult = await ori(expression, { parent });
15
- return lastResult;
26
+
27
+ const regex = /^\d+,(?<expression>.*)$/;
28
+ const match = normalizedKey.match(regex);
29
+ let value;
30
+ if (match) {
31
+ const expression = decodeURIComponent(match.groups.expression);
32
+ value = await evaluate(expression, {
33
+ globals,
34
+ mode: "shell",
35
+ parent,
36
+ });
37
+ if (
38
+ (Tree.isMaplike(value) && typeof value !== "function") ||
39
+ value?.unpack
40
+ ) {
41
+ value = debugTransform(value);
42
+ }
43
+ }
44
+
45
+ mapParentToResult.set(parent, { key: normalizedKey, value });
46
+
47
+ return value;
16
48
  };
17
49
  }
package/src/dev/dev.js CHANGED
@@ -6,13 +6,13 @@ export { default as indexPage } from "../origami/indexPage.js";
6
6
  export { default as yaml } from "../origami/yaml.js";
7
7
  export { default as breakpoint } from "./breakpoint.js";
8
8
  export { default as changes } from "./changes.js";
9
+ export { default as changes2 } from "./changes2.js";
9
10
  export { default as code } from "./code.js";
10
11
  export { default as copy } from "./copy.js";
11
12
  export { default as audit } from "./crawler/audit.js";
12
13
  export { default as crawl } from "./crawler/crawl.js";
13
14
  export { default as debug } from "./debug.js";
14
15
  export { default as debug2 } from "./debug2/debug2.js";
15
- export { default as eval } from "./debug2/oriEval.js";
16
16
  export { default as explore } from "./explore.js";
17
17
  export { default as help } from "./help.js";
18
18
  export { default as log } from "./log.js";
package/src/dev/help.yaml CHANGED
@@ -68,9 +68,15 @@ Origami:
68
68
  fetch:
69
69
  args: (url, options)
70
70
  description: Fetch a resource from a URL with support for extensions
71
+ hash:
72
+ args: (data)
73
+ description: A hex string hash of the data
71
74
  htmlEscape:
72
75
  args: (text)
73
76
  description: Escape HTML entities in the text
77
+ htmlParse:
78
+ args: (html)
79
+ description: Parse HTML into a plain JavaScript object
74
80
  image:
75
81
  description: Collection of functions for working with images
76
82
  indexPage:
@@ -107,6 +113,12 @@ Origami:
107
113
  description: POST the given data to the URL
108
114
  projectRoot:
109
115
  description: The root folder for the current Origami project
116
+ randomFrom:
117
+ args: (data)
118
+ description: Returns a random value based on the data
119
+ randomsFrom:
120
+ args: (data)
121
+ description: Returns a function to produce random values based on the data
110
122
  redirect:
111
123
  args: (url, options)
112
124
  description: Redirect to the given URL
@@ -139,6 +151,9 @@ Origami:
139
151
  unpack:
140
152
  args: (buffer)
141
153
  description: Unpack the buffer into a usable form
154
+ xmlParse:
155
+ args: (xml)
156
+ description: Parse XML into a plain JavaScript object
142
157
  yaml:
143
158
  args: (obj)
144
159
  description: Render the object in YAML format
@@ -187,6 +202,9 @@ Tree:
187
202
  clear:
188
203
  args: (map)
189
204
  description: Remove all values from the map
205
+ combine:
206
+ args: (tree1, tree2, combineFn)
207
+ description: Pairwise application of combineFn to the trees' values
190
208
  concat:
191
209
  args: (...trees)
192
210
  description: Merge trees, renumbering numeric keys
@@ -0,0 +1 @@
1
+ export { default as xml_handler } from "./xml_handler.js";
@@ -0,0 +1,23 @@
1
+ import { symbols, toString } from "@weborigami/async-tree";
2
+ import xmlParse from "../origami/xmlParse.js";
3
+
4
+ export default {
5
+ mediaType: "application/xml",
6
+
7
+ async unpack(packed, options = {}) {
8
+ const parent = options.parent ?? null;
9
+ const text = toString(packed);
10
+ if (text === null) {
11
+ throw new TypeError("XML handler can only unpack text.");
12
+ }
13
+ const data = await xmlParse(text);
14
+ // Define `parent` as non-enumerable property
15
+ Object.defineProperty(data, symbols.parent, {
16
+ configurable: true,
17
+ enumerable: false,
18
+ value: parent,
19
+ writable: true,
20
+ });
21
+ return data;
22
+ },
23
+ };