@weborigami/origami 0.6.16 → 0.7.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@weborigami/origami",
3
- "version": "0.6.16",
3
+ "version": "0.7.0-beta.1",
4
4
  "description": "Web Origami language, CLI, framework, and server",
5
5
  "type": "module",
6
6
  "repository": {
@@ -13,24 +13,24 @@
13
13
  "main": "./main.js",
14
14
  "types": "./index.ts",
15
15
  "devDependencies": {
16
- "@types/node": "25.3.2",
17
- "typescript": "5.9.3"
16
+ "@types/node": "25.9.1",
17
+ "typescript": "6.0.3"
18
18
  },
19
19
  "dependencies": {
20
- "@hpcc-js/wasm-graphviz": "^1.21.0",
21
- "@weborigami/async-tree": "0.6.16",
20
+ "@hpcc-js/wasm-graphviz": "1.21.7",
21
+ "@weborigami/async-tree": "0.7.0-beta.1",
22
22
  "@weborigami/json-feed-to-rss": "1.0.1",
23
- "@weborigami/language": "0.6.16",
24
- "css-tree": "3.1.0",
23
+ "@weborigami/language": "0.7.0-beta.1",
24
+ "css-tree": "3.2.1",
25
25
  "highlight.js": "11.11.1",
26
- "jsdom": "28.1.0",
27
- "marked": "17.0.3",
28
- "marked-gfm-heading-id": "4.1.3",
29
- "marked-highlight": "2.2.3",
30
- "marked-smartypants": "1.1.11",
26
+ "jsdom": "29.1.1",
27
+ "marked": "18.0.4",
28
+ "marked-gfm-heading-id": "4.1.4",
29
+ "marked-highlight": "2.2.4",
30
+ "marked-smartypants": "1.1.12",
31
31
  "sharp": "0.34.5",
32
32
  "whatwg-mimetype": "5.0.0",
33
- "yaml": "2.8.2"
33
+ "yaml": "2.9.0"
34
34
  },
35
35
  "scripts": {
36
36
  "test": "node --test --test-reporter=spec",
package/src/cli/cli.js CHANGED
@@ -1,7 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { Tree } from "@weborigami/async-tree";
4
- import { formatError, projectRootFromPath } from "@weborigami/language";
4
+ import {
5
+ activeProjectRoot,
6
+ formatError,
7
+ projectRootFromPath,
8
+ } from "@weborigami/language";
5
9
  import path from "node:path";
6
10
  import process, { stdout } from "node:process";
7
11
  import help from "../dev/help.js";
@@ -12,10 +16,6 @@ const TypedArray = Object.getPrototypeOf(Uint8Array);
12
16
  async function main(...args) {
13
17
  const expression = args.join(" ");
14
18
 
15
- // Find the project root.
16
- const currentDirectory = process.cwd();
17
- const projectRoot = await projectRootFromPath(currentDirectory);
18
-
19
19
  // If no arguments were passed, show usage.
20
20
  if (!expression) {
21
21
  const usage = await help();
@@ -23,6 +23,11 @@ async function main(...args) {
23
23
  return;
24
24
  }
25
25
 
26
+ // Find the project root.
27
+ const currentDirectory = process.cwd();
28
+ const projectRoot = await projectRootFromPath(currentDirectory);
29
+ activeProjectRoot.set(projectRoot);
30
+
26
31
  // Traverse from the project root to the current directory.
27
32
  const relative = path.relative(projectRoot.path, currentDirectory);
28
33
  const parent = await Tree.traversePath(projectRoot, relative);
@@ -0,0 +1,26 @@
1
+ import { toString } from "@weborigami/async-tree";
2
+ import { createHash } from "node:crypto";
3
+
4
+ /**
5
+ * Given data, return a 256-bit hash of that data. The data can be a string or a
6
+ * Uint8Array.
7
+ *
8
+ * @typedef {import("@weborigami/async-tree").Stringlike} Stringlike
9
+ *
10
+ * @param {Uint8Array|Stringlike} data
11
+ */
12
+ export default function hashBytes(data) {
13
+ let bytes;
14
+ if (data instanceof Uint8Array) {
15
+ bytes = data;
16
+ } else {
17
+ const text = toString(data);
18
+ if (!text) {
19
+ throw new TypeError("Data must be a string or Uint8Array");
20
+ }
21
+ bytes = new TextEncoder().encode(text);
22
+ }
23
+
24
+ const hash = createHash("sha256").update(bytes).digest();
25
+ return hash;
26
+ }
@@ -43,6 +43,9 @@ export async function toJson(object) {
43
43
  * @returns {Promise<string>}
44
44
  */
45
45
  export async function toYaml(object) {
46
+ // TODO: The toPlainValue will remove trailing slashes from keys, which should
47
+ // only happen for maps that support trailing slash keys. For maps that don't,
48
+ // we should preserve trailing slashes.
46
49
  const serializable = await toPlainValue(object, reduceToMap);
47
50
  return YAML.stringify(serializable);
48
51
  }
@@ -1,5 +1,5 @@
1
1
  import { isUnpackable, scope, trailingSlash } from "@weborigami/async-tree";
2
- import { projectGlobals } from "@weborigami/language";
2
+ import { getGlobalsForTree } from "@weborigami/language";
3
3
 
4
4
  /**
5
5
  * Add support for commands prefixed with `!`.
@@ -28,11 +28,11 @@ export default function OriCommandTransform(Base) {
28
28
  }
29
29
 
30
30
  // Key is an Origami command; invoke it.
31
- const globals = await projectGlobals(/** @type {any} */ (this));
31
+ const globals = getGlobalsForTree(this);
32
32
  const commandName = trailingSlash.remove(key.slice(1).trim());
33
33
 
34
34
  // Look for command as a global or Dev command
35
- const command = globals[commandName] ?? globals.Dev?.[commandName];
35
+ const command = globals?.[commandName] ?? globals?.Dev?.[commandName];
36
36
  if (command) {
37
37
  value = await command(this);
38
38
  } else {
@@ -1,10 +1,4 @@
1
- import {
2
- args,
3
- isStringlike,
4
- toString,
5
- trailingSlash,
6
- Tree,
7
- } from "@weborigami/async-tree";
1
+ import { args, isStringlike, toString, Tree } from "@weborigami/async-tree";
8
2
 
9
3
  /**
10
4
  * Given an old tree and a new tree, return a tree of changes indicated
@@ -25,51 +19,20 @@ export default async function changes(oldMaplike, newMaplike) {
25
19
  position: 2,
26
20
  });
27
21
 
28
- const oldKeys = await Tree.keys(oldTree);
29
- const newKeys = await Tree.keys(newTree);
30
-
31
- const oldKeysNormalized = oldKeys.map(trailingSlash.remove);
32
- const newKeysNormalized = newKeys.map(trailingSlash.remove);
33
-
34
- let result;
35
-
36
- for (const oldKey of oldKeys) {
37
- const oldNormalized = trailingSlash.remove(oldKey);
38
- if (!newKeysNormalized.includes(oldNormalized)) {
39
- result ??= {};
40
- result[oldKey] = "deleted";
41
- continue;
42
- }
43
-
44
- const oldValue = await oldTree.get(oldKey);
45
- const newValue = await newTree.get(oldKey);
46
-
47
- if (Tree.isMap(oldValue) && Tree.isMap(newValue)) {
48
- const treeChanges = await changes(oldValue, newValue);
49
- if (treeChanges && Object.keys(treeChanges).length > 0) {
50
- result ??= {};
51
- result[oldKey] = treeChanges;
52
- }
53
- } else if (isStringlike(oldValue) && isStringlike(newValue)) {
54
- const oldText = toString(oldValue);
55
- const newText = toString(newValue);
56
- if (oldText !== newText) {
57
- result ??= {};
58
- result[oldKey] = "changed";
59
- }
60
- } else {
61
- result ??= {};
62
- result[oldKey] = "changed";
63
- }
64
- }
22
+ const combination = await Tree.combine(oldTree, newTree, compare);
23
+ return combination;
24
+ }
65
25
 
66
- for (const newKey of newKeys) {
67
- const newNormalized = trailingSlash.remove(newKey);
68
- if (!oldKeysNormalized.includes(newNormalized)) {
69
- result ??= {};
70
- result[newKey] = "added";
71
- }
26
+ function compare(oldValue, newValue) {
27
+ if (oldValue !== undefined && newValue === undefined) {
28
+ return "deleted";
29
+ } else if (oldValue === undefined && newValue !== undefined) {
30
+ return "added";
31
+ } else if (isStringlike(oldValue) && isStringlike(newValue)) {
32
+ const oldText = toString(oldValue);
33
+ const newText = toString(newValue);
34
+ return oldText === newText ? undefined : "changed";
35
+ } else {
36
+ return oldValue === newValue ? undefined : "changed";
72
37
  }
73
-
74
- return result;
75
38
  }
@@ -1,5 +1,3 @@
1
- import { OrigamiFileMap } from "@weborigami/language";
2
- import path from "node:path";
3
1
  import debugParent from "./debugParent.js";
4
2
 
5
3
  /**
@@ -42,39 +40,7 @@ export default async function debug2(code, state) {
42
40
  parentPath,
43
41
  });
44
42
 
45
- // Watch the parent files for changes
46
- const tree = new OrigamiFileMap(parentPath);
47
- tree.watch();
48
- tree.addEventListener?.("change", async (event) => {
49
- // @ts-ignore
50
- const { filePath } = event.options;
51
- if (isJavaScriptFile(filePath)) {
52
- // Need to restart the child process
53
- console.log("JavaScript file changed, restarting server…");
54
- await server.restart();
55
- } else if (path.basename(filePath) === "package.json") {
56
- // Need to restart the child process
57
- console.log("package.json changed, restarting server…");
58
- await server.restart();
59
- } else {
60
- // Just have the child reevaluate the expression
61
- console.log("File changed, reloading site…");
62
- await server.reevaluate();
63
- }
64
- });
65
-
66
- // When server closes, stop watching for file changes
67
- server.on("close", () => {
68
- tree.unwatch();
69
- });
70
-
71
43
  console.log(`Server running at ${server.origin}. Press Ctrl+C to stop.`);
72
44
  }
73
45
  debug2.needsState = true;
74
46
  debug2.unevaluatedArgs = true;
75
-
76
- function isJavaScriptFile(filePath) {
77
- const extname = path.extname(filePath).toLowerCase();
78
- const jsExtensions = [".cjs", ".js", ".mjs", ".ts"];
79
- return jsExtensions.includes(extname);
80
- }
@@ -1,4 +1,11 @@
1
+ import { AsyncMap, Tree } from "@weborigami/async-tree";
2
+ import {
3
+ activeProjectRoot,
4
+ projectRootFromPath,
5
+ systemCache,
6
+ } from "@weborigami/language";
1
7
  import http from "node:http";
8
+ import path from "node:path";
2
9
  import { requestListener } from "../../server/server.js";
3
10
  import expressionTree from "./expressionTree.js";
4
11
 
@@ -36,13 +43,26 @@ if (parentPath === undefined) {
36
43
  fail("Missing Origami parent");
37
44
  }
38
45
 
39
- const quiet = process.env.ORIGAMI_QUIET === "1";
46
+ const projectRoot = await projectRootFromPath(parentPath);
47
+ activeProjectRoot.set(projectRoot);
48
+ projectRoot.watch();
40
49
 
41
- // An indirect pointer to the tree of resources;
42
- let treeHandle = {};
50
+ // Traverse from the project root to the indicated parent.
51
+ const relative = path.relative(projectRoot.path, parentPath);
52
+ const parent = await Tree.traversePath(projectRoot, relative);
43
53
 
44
- // Initial evaluation of the expression
45
- await evaluateExpression();
54
+ // Notify parent if a file changes
55
+ projectRoot.addEventListener("change", async (/** @type {any} */ event) => {
56
+ const { filePath } = event.options;
57
+ if (filePath) {
58
+ process.send?.({ type: "VALUE_CHANGE", path: filePath });
59
+ }
60
+ });
61
+
62
+ const quiet = process.env.ORIGAMI_QUIET === "1";
63
+
64
+ // Get a handle to the tree produced by evaluating the expression
65
+ const treeHandle = await handleToEvaluatedExpression(expression, parent);
46
66
 
47
67
  // Serve the tree of resources
48
68
  const listener = requestListener(treeHandle, { quiet });
@@ -59,20 +79,26 @@ server.on("connection", (socket) => {
59
79
  server.keepAliveTimeout = 1000;
60
80
  server.headersTimeout = 5000;
61
81
 
62
- // Draining state
63
- let draining = false;
82
+ // Closing state
83
+ let closing = false;
64
84
  let serverClosed = false;
65
85
 
66
- function beginDrain() {
67
- if (draining) return;
68
- draining = true;
86
+ function beginClose() {
87
+ if (closing) {
88
+ return;
89
+ }
90
+
91
+ closing = true;
69
92
 
70
93
  // Stop accepting new connections.
71
94
  server.close(() => {
72
95
  serverClosed = true;
73
- maybeFinishDrain();
96
+ maybeFinishClose();
74
97
  });
75
98
 
99
+ // Stop watching files
100
+ projectRoot.unwatch();
101
+
76
102
  // Give in-flight requests a moment, then force-close remaining sockets.
77
103
  const GRACE_MS = 1200;
78
104
  setTimeout(() => {
@@ -81,7 +107,7 @@ function beginDrain() {
81
107
  socket.destroy();
82
108
  }
83
109
  // socket "close" events will shrink the set; check again soon.
84
- setTimeout(maybeFinishDrain, 50).unref();
110
+ setTimeout(maybeFinishClose, 50).unref();
85
111
  }, GRACE_MS).unref();
86
112
 
87
113
  // Absolute last resort: don’t hang forever.
@@ -89,50 +115,54 @@ function beginDrain() {
89
115
  setTimeout(() => process.exit(0), HARD_MS).unref();
90
116
  }
91
117
 
92
- async function evaluateExpression() {
93
- const tree = await expressionTree({
94
- expression,
95
- parentPath,
118
+ async function handleToEvaluatedExpression(expression, parent) {
119
+ const handle = Object.assign(new AsyncMap(), {
120
+ async get(key) {
121
+ const tree = await this.getTree();
122
+ return tree.get(key);
123
+ },
124
+
125
+ async getTree() {
126
+ const tree = await systemCache.getOrInsertComputedAsync(
127
+ "_debug",
128
+ async () =>
129
+ expressionTree({
130
+ expression,
131
+ parent,
132
+ }),
133
+ );
134
+ return tree;
135
+ },
136
+
137
+ async keys() {
138
+ const tree = await this.getTree();
139
+ return tree.keys();
140
+ },
96
141
  });
97
- if (!tree) {
98
- fail("Dev.debug2: expression did not evaluate to a maplike resource tree");
99
- }
100
- Object.setPrototypeOf(treeHandle, tree);
101
142
 
102
- // Clean the handle of any named properties or symbols that have been set
103
- // directly on it.
104
- try {
105
- for (const key of Object.getOwnPropertyNames(treeHandle)) {
106
- delete treeHandle[key];
107
- }
108
- for (const key of Object.getOwnPropertySymbols(treeHandle)) {
109
- delete treeHandle[key];
110
- }
111
- } catch {
112
- // Ignore errors.
113
- }
143
+ // Trigger initial expression evaluation but don't wait for it. This lets some
144
+ // evaluation happen while the user launches/refreshes their browser.
145
+ handle.getTree();
114
146
 
115
- process.send?.({ type: "EVALUATED" });
147
+ return handle;
116
148
  }
117
149
 
118
- function maybeFinishDrain() {
119
- if (!draining) return;
150
+ function maybeFinishClose() {
151
+ if (!closing) return;
120
152
  if (serverClosed && sockets.size === 0) {
121
- process.send?.({ type: "DRAINED" });
153
+ process.send?.({ type: "CLOSED" });
122
154
  process.exit(0);
123
155
  }
124
156
  }
125
157
 
126
- // Drain when instructed by parent, or if parent dies.
158
+ // Close when instructed by parent, or if parent dies.
127
159
  process.on("message", async (/** @type {any} */ message) => {
128
- if (message?.type === "DRAIN") {
129
- beginDrain();
130
- } else if (message?.type === "REEVALUATE") {
131
- await evaluateExpression();
160
+ if (message?.type === "CLOSE") {
161
+ beginClose();
132
162
  }
133
163
  });
134
- process.on("SIGTERM", beginDrain);
135
- process.on("SIGINT", beginDrain);
164
+ process.on("SIGTERM", beginClose);
165
+ process.on("SIGINT", beginClose);
136
166
 
137
167
  process.on("disconnect", () => {
138
168
  // Parent process died, exit immediately
@@ -8,4 +8,5 @@ export { default as index } from "../../origami/indexPage.js";
8
8
  export { default as yaml } from "../../origami/yaml.js";
9
9
  export { default as explore } from "../explore.js";
10
10
  export { default as svg } from "../svg.js";
11
+ export { default as syscache } from "../syscache.js";
11
12
  export { default as version } from "../version.js";
@@ -1,10 +1,9 @@
1
1
  import { fork } from "node:child_process";
2
2
  import { EventEmitter } from "node:events";
3
3
  import http from "node:http";
4
+ import path from "node:path";
4
5
  import { findOpenPort } from "../../common/findOpenPort.js";
5
6
 
6
- const PUBLIC_HOST = "127.0.0.1";
7
-
8
7
  // Module that loads the server in the child process
9
8
  const childModuleUrl = new URL("./debugChild.js", import.meta.url);
10
9
 
@@ -58,20 +57,18 @@ export default async function debugParent(options) {
58
57
  }
59
58
 
60
59
  const port = options.port ?? (await findOpenPort());
61
- publicOrigin = `http://${PUBLIC_HOST}:${port}`;
60
+ publicOrigin = `http://localhost:${port}`;
62
61
 
63
62
  publicServer = http.createServer(proxyRequest);
64
- await new Promise((resolve) =>
65
- publicServer.listen(port, PUBLIC_HOST, resolve),
66
- );
63
+ await new Promise((resolve) => publicServer.listen(port, undefined, resolve));
67
64
  await startChild(options);
68
65
 
69
66
  emitter = Object.assign(new EventEmitter(), {
70
67
  close,
71
68
  origin: publicOrigin,
72
- reevaluate,
73
69
  restart: () => startChild(options),
74
70
  });
71
+
75
72
  return emitter;
76
73
  }
77
74
 
@@ -112,16 +109,16 @@ async function drainAndStopChild(childProcess) {
112
109
  return;
113
110
  }
114
111
 
115
- // Ask it to drain first.
112
+ // Ask it to close first.
116
113
  try {
117
- childProcess.send({ type: "DRAIN" });
114
+ childProcess.send({ type: "CLOSE" });
118
115
  } catch {
119
116
  // ignore
120
117
  }
121
118
 
122
- const drained = new Promise((resolve) => {
119
+ const closed = new Promise((resolve) => {
123
120
  const onMessage = (msg) => {
124
- if (msg && typeof msg === "object" && msg.type === "DRAINED") {
121
+ if (msg && typeof msg === "object" && msg.type === "CLOSED") {
125
122
  cleanup(resolve);
126
123
  }
127
124
  };
@@ -140,7 +137,7 @@ async function drainAndStopChild(childProcess) {
140
137
  // Give it a short grace window to finish in-flight work.
141
138
  const GRACE_MS = 1500;
142
139
  await Promise.race([
143
- drained,
140
+ closed,
144
141
  new Promise((r) => setTimeout(r, GRACE_MS).unref()),
145
142
  ]);
146
143
 
@@ -157,6 +154,20 @@ async function drainAndStopChild(childProcess) {
157
154
  }, GRACE_MS).unref();
158
155
  }
159
156
 
157
+ function isJavaScriptFile(filePath) {
158
+ const extname = path.extname(filePath).toLowerCase();
159
+ const jsExtensions = [".cjs", ".js", ".mjs", ".ts"];
160
+ return jsExtensions.includes(extname);
161
+ }
162
+
163
+ async function onFileChange(filePath) {
164
+ if (isJavaScriptFile(filePath)) {
165
+ // Need to restart the child process
166
+ console.log("JavaScript file changed, restarting server…");
167
+ await emitter.restart();
168
+ }
169
+ }
170
+
160
171
  /**
161
172
  * Proxy incoming requests to the active child server, or return a 503 if not
162
173
  * ready.
@@ -186,7 +197,7 @@ function proxyRequest(request, response) {
186
197
 
187
198
  const upstreamRequest = http.request(
188
199
  {
189
- host: PUBLIC_HOST,
200
+ host: "localhost",
190
201
  port,
191
202
  method: request.method,
192
203
  path: request.url,
@@ -237,30 +248,6 @@ function proxyRequest(request, response) {
237
248
  request.pipe(upstreamRequest);
238
249
  }
239
250
 
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
-
264
251
  /**
265
252
  * Start a new child process.
266
253
  *
@@ -332,11 +319,12 @@ function startChild(options) {
332
319
  // console.log("Child process superseded by newer one, killing it...");
333
320
  childProcess.kill("SIGTERM");
334
321
  }
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
- }
322
+ } else if (
323
+ message.type === "VALUE_CHANGE" &&
324
+ typeof message.path === "string"
325
+ ) {
326
+ // REVIEW: We don't await this -- is that a problem?
327
+ onFileChange(message.path);
340
328
  } else if (message.type === "FATAL") {
341
329
  // Child couldn't start (import error, etc.)
342
330
  // Keep previous active child if any; otherwise we'll serve 500/503.
@@ -23,27 +23,10 @@ import * as debugCommands from "./debugCommands.js";
23
23
  * Also transform a simple object result to YAML for viewing.
24
24
  *
25
25
  * @typedef {import("@weborigami/async-tree").Maplike} Maplike
26
- * @typedef {import("@weborigami/async-tree").Packed} Packed
27
26
  *
28
- * @param {Maplike|Packed} input
27
+ * @param {Maplike} input
29
28
  */
30
29
  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
- }
46
-
47
30
  const source = Tree.from(input, { deep: true });
48
31
 
49
32
  return Object.assign(new AsyncMap(), {
@@ -85,8 +68,10 @@ export default function debugTransform(input) {
85
68
 
86
69
  // Ensure this transform is applied to any map result, or any object with
87
70
  // an unpack method that returns a map.
88
- if (Tree.isMap(value) || value?.unpack) {
71
+ if (Tree.isMap(value)) {
89
72
  value = debugTransform(value);
73
+ } else if (value?.unpack) {
74
+ value = debugPackedValue(value);
90
75
  }
91
76
 
92
77
  return value;
@@ -99,15 +84,42 @@ export default function debugTransform(input) {
99
84
  // If this value is given to the server, the server will call this pack()
100
85
  // method. We respond with the index page.
101
86
  async pack() {
102
- return this.get("index.html");
87
+ // @ts-ignore
88
+ return source.pack?.() ?? this.get("index.html");
103
89
  },
104
90
 
91
+ // @ts-ignore
92
+ parent: source.parent,
93
+
105
94
  source,
106
95
 
107
96
  trailingSlashKeys: true,
108
97
  });
109
98
  }
110
99
 
100
+ /**
101
+ * If the value isn't a tree, but has a tree attached via an `unpack` method,
102
+ * destructively wrap the unpack method to add this transform.
103
+ *
104
+ * @typedef {import("@weborigami/async-tree").Packed} Packed
105
+ * @param {Packed} packed
106
+ */
107
+ function debugPackedValue(packed) {
108
+ if (isUnpackable(packed)) {
109
+ const original = packed.unpack.bind(packed);
110
+ packed.unpack = async () => {
111
+ const content = await original();
112
+ if (!Tree.isTraversable(content) || typeof content === "function") {
113
+ return content;
114
+ }
115
+ /** @type {any} */
116
+ let tree = Tree.from(content);
117
+ return debugTransform(tree);
118
+ };
119
+ }
120
+ return packed;
121
+ }
122
+
111
123
  async function invokeOrigamiCommand(tree, key) {
112
124
  // Key is an Origami command; invoke it.
113
125
  const commandName = trailingSlash.remove(key.slice(1).trim());