@weborigami/origami 0.6.15 → 0.6.16

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/main.js CHANGED
@@ -1,7 +1,9 @@
1
1
  export { default as documentObject } from "./src/common/documentObject.js";
2
2
  export * from "./src/common/serialize.js";
3
3
  export { default as debugParent } from "./src/dev/debug2/debugParent.js";
4
+ export { default as debugTransform } from "./src/dev/debug2/debugTransform.js";
4
5
  export * as Dev from "./src/dev/dev.js";
6
+ export * from "./src/handlers/origamiHandlers.js";
5
7
  export * as Origami from "./src/origami/origami.js";
6
8
  export { default as origamiHighlightDefinition } from "./src/origami/origamiHighlightDefinition.js";
7
9
  export { default as constructResponse } from "./src/server/constructResponse.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@weborigami/origami",
3
- "version": "0.6.15",
3
+ "version": "0.6.16",
4
4
  "description": "Web Origami language, CLI, framework, and server",
5
5
  "type": "module",
6
6
  "repository": {
@@ -18,9 +18,9 @@
18
18
  },
19
19
  "dependencies": {
20
20
  "@hpcc-js/wasm-graphviz": "^1.21.0",
21
- "@weborigami/async-tree": "0.6.15",
21
+ "@weborigami/async-tree": "0.6.16",
22
22
  "@weborigami/json-feed-to-rss": "1.0.1",
23
- "@weborigami/language": "0.6.15",
23
+ "@weborigami/language": "0.6.16",
24
24
  "css-tree": "3.1.0",
25
25
  "highlight.js": "11.11.1",
26
26
  "jsdom": "28.1.0",
@@ -29,6 +29,7 @@
29
29
  "marked-highlight": "2.2.3",
30
30
  "marked-smartypants": "1.1.11",
31
31
  "sharp": "0.34.5",
32
+ "whatwg-mimetype": "5.0.0",
32
33
  "yaml": "2.8.2"
33
34
  },
34
35
  "scripts": {
@@ -0,0 +1,58 @@
1
+ import net from "node:net";
2
+
3
+ const DEFAULT_PORT = 5000;
4
+
5
+ /**
6
+ * Return the first open port number on or after the given port number.
7
+ *
8
+ * @param {number} startPort
9
+ * @returns {Promise<number>}
10
+ */
11
+ export async function findOpenPort(startPort = DEFAULT_PORT) {
12
+ for (let port = startPort; port <= 65535; port++) {
13
+ if (await isPortAvailable(port)) {
14
+ return port;
15
+ }
16
+ }
17
+
18
+ throw new Error(`No open port found on or after ${startPort}`);
19
+ }
20
+
21
+ /**
22
+ * Check whether a port is available on both IPv4 and IPv6 loopback addresses
23
+ * by attempting TCP connections. On macOS, IPv4 and IPv6 port spaces are
24
+ * independent (IPV6_V6ONLY=1 by default), so a server bound to [::]:PORT is
25
+ * invisible to a 127.0.0.1 bind check. Using connect probes on both loopbacks
26
+ * catches servers regardless of which protocol family they listen on. Any
27
+ * connection error (ECONNREFUSED, EADDRNOTAVAIL, etc.) means nothing is
28
+ * listening there, so the function is safe on systems without IPv6.
29
+ *
30
+ * @param {number} port
31
+ * @returns {Promise<boolean>}
32
+ */
33
+ async function isPortAvailable(port) {
34
+ const [v4, v6] = await Promise.all([
35
+ isPortListening("127.0.0.1", port),
36
+ isPortListening("::1", port),
37
+ ]);
38
+ return !v4 && !v6;
39
+ }
40
+
41
+ /**
42
+ * @param {string} host
43
+ * @param {number} port
44
+ * @returns {Promise<boolean>}
45
+ */
46
+ function isPortListening(host, port) {
47
+ return new Promise((resolve) => {
48
+ const socket = net.createConnection(port, host);
49
+ socket.once("connect", () => {
50
+ socket.destroy();
51
+ resolve(true);
52
+ });
53
+ socket.once("error", () => {
54
+ socket.destroy();
55
+ resolve(false);
56
+ });
57
+ });
58
+ }
@@ -1,4 +1,5 @@
1
- import { execute } from "@weborigami/language";
1
+ import { OrigamiFileMap } from "@weborigami/language";
2
+ import path from "node:path";
2
3
  import debugParent from "./debugParent.js";
3
4
 
4
5
  /**
@@ -9,25 +10,13 @@ import debugParent from "./debugParent.js";
9
10
  * extract the source code of the expression to be debugged. (If it were
10
11
  * evaluated, the function will be called with the result of the expression.)
11
12
  *
12
- * The `options` argument can include:
13
- * - `enableUnsafeEval`: if true, enables the `!eval` debug command in the child
14
- * process; default is false
15
- * - `debugFilesPath`: path to resources that will be added to the served tree
16
- *
17
13
  * @typedef {import("@weborigami/language").RuntimeState} RuntimeState
18
14
  * @typedef {import("@weborigami/language").AnnotatedCode} AnnotatedCode
19
15
  *
20
16
  * @param {AnnotatedCode} code
21
- * @param {any | RuntimeState} options
22
17
  * @param {RuntimeState} state
23
18
  */
24
- export default async function debug2(code, options, state) {
25
- if (state === undefined) {
26
- // Options were omitted; shift arguments
27
- state = options;
28
- options = [];
29
- }
30
-
19
+ export default async function debug2(code, state) {
31
20
  if (
32
21
  !(code instanceof Array) ||
33
22
  code.source === undefined ||
@@ -47,23 +36,45 @@ export default async function debug2(code, options, state) {
47
36
  throw new Error("Dev.debug2 couldn't work out the parent path.");
48
37
  }
49
38
 
50
- // Need to evaluate options object
51
- if (options.length > 0) {
52
- options = await execute(options, state);
53
- } else {
54
- options = {};
55
- }
56
-
57
- // @ts-ignore
58
- const enableUnsafeEval = options.enableUnsafeEval ?? false;
59
- const debugFilesPath = options.debugFilesPath ?? "";
60
-
61
- await debugParent({
62
- debugFilesPath,
63
- enableUnsafeEval,
39
+ // Start the debug server
40
+ const server = await debugParent({
64
41
  expression,
65
42
  parentPath,
66
43
  });
44
+
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
+ console.log(`Server running at ${server.origin}. Press Ctrl+C to stop.`);
67
72
  }
68
73
  debug2.needsState = true;
69
74
  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
+ }
@@ -22,13 +22,6 @@ function fail(message) {
22
22
  process.exit(1);
23
23
  }
24
24
 
25
- /** @type {string} */
26
- const debugFilesPath = process.env.ORIGAMI_DEBUG_FILES_PATH ?? "";
27
-
28
- /** @type {boolean} */
29
- // @ts-ignore
30
- const enableUnsafeEval = process.env.ORIGAMI_ENABLE_UNSAFE_EVAL === "1";
31
-
32
25
  /** @type {string} */
33
26
  // @ts-ignore
34
27
  const expression = process.env.ORIGAMI_EXPRESSION;
@@ -43,6 +36,8 @@ if (parentPath === undefined) {
43
36
  fail("Missing Origami parent");
44
37
  }
45
38
 
39
+ const quiet = process.env.ORIGAMI_QUIET === "1";
40
+
46
41
  // An indirect pointer to the tree of resources;
47
42
  let treeHandle = {};
48
43
 
@@ -50,7 +45,7 @@ let treeHandle = {};
50
45
  await evaluateExpression();
51
46
 
52
47
  // Serve the tree of resources
53
- const listener = requestListener(treeHandle);
48
+ const listener = requestListener(treeHandle, { quiet });
54
49
  const server = http.createServer(listener);
55
50
 
56
51
  // Track live connections so we can drain/close cleanly.
@@ -96,10 +91,8 @@ function beginDrain() {
96
91
 
97
92
  async function evaluateExpression() {
98
93
  const tree = await expressionTree({
99
- debugFilesPath,
100
94
  expression,
101
95
  parentPath,
102
- enableUnsafeEval,
103
96
  });
104
97
  if (!tree) {
105
98
  fail("Dev.debug2: expression did not evaluate to a maplike resource tree");
@@ -118,6 +111,8 @@ async function evaluateExpression() {
118
111
  } catch {
119
112
  // Ignore errors.
120
113
  }
114
+
115
+ process.send?.({ type: "EVALUATED" });
121
116
  }
122
117
 
123
118
  function maybeFinishDrain() {
@@ -1,29 +1,11 @@
1
1
  // Subset of commands made available via debugTransform
2
2
 
3
3
  import { Tree } from "@weborigami/async-tree";
4
+ export const keys = Tree.keys;
5
+ export const json = Tree.json;
4
6
 
5
- import index from "../../origami/indexPage.js";
6
- import yaml from "../../origami/yaml.js";
7
- import explore from "../explore.js";
8
- import svg from "../svg.js";
9
- import version from "../version.js";
10
- import oriEval from "./oriEval.js";
11
-
12
- export default function debugCommands(enableUnsafeEval = false) {
13
- return Object.assign(
14
- {
15
- keys: Tree.keys,
16
- json: Tree.json,
17
- index,
18
- yaml,
19
- explore,
20
- svg,
21
- version,
22
- },
23
- enableUnsafeEval
24
- ? {
25
- eval: oriEval,
26
- }
27
- : {},
28
- );
29
- }
7
+ export { default as index } from "../../origami/indexPage.js";
8
+ export { default as yaml } from "../../origami/yaml.js";
9
+ export { default as explore } from "../explore.js";
10
+ export { default as svg } from "../svg.js";
11
+ export { default as version } from "../version.js";
@@ -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
@@ -12,7 +12,6 @@ export { default as audit } from "./crawler/audit.js";
12
12
  export { default as crawl } from "./crawler/crawl.js";
13
13
  export { default as debug } from "./debug.js";
14
14
  export { default as debug2 } from "./debug2/debug2.js";
15
- export { default as eval } from "./debug2/oriEval.js";
16
15
  export { default as explore } from "./explore.js";
17
16
  export { default as help } from "./help.js";
18
17
  export { default as log } from "./log.js";
package/src/dev/help.yaml CHANGED
@@ -79,6 +79,9 @@ Origami:
79
79
  inline:
80
80
  args: (text)
81
81
  description: Inline Origami expressions found in the text
82
+ htmlParse:
83
+ args: (html)
84
+ description: Parse HTML into a plain JavaScript object
82
85
  json:
83
86
  args: (obj)
84
87
  description: Render the object in JSON format
@@ -139,6 +142,9 @@ Origami:
139
142
  unpack:
140
143
  args: (buffer)
141
144
  description: Unpack the buffer into a usable form
145
+ xmlParse:
146
+ args: (xml)
147
+ description: Parse XML into a plain JavaScript object
142
148
  yaml:
143
149
  args: (obj)
144
150
  description: Render the object in YAML format
@@ -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
+ };
@@ -0,0 +1,93 @@
1
+ const ELEMENT_NODE = 1;
2
+ const TEXT_NODE = 3;
3
+ const CDATA_SECTION_NODE = 4;
4
+ const DOCUMENT_NODE = 9;
5
+ const DOCUMENT_FRAGMENT_NODE = 11;
6
+
7
+ export default function domNodeToObject(node) {
8
+ switch (node.nodeType) {
9
+ case DOCUMENT_NODE:
10
+ return {
11
+ name: "#document",
12
+ children: [...node.childNodes]
13
+ .filter((child) => !isWhitespaceOnly(child))
14
+ .map(domNodeToObject),
15
+ };
16
+
17
+ case DOCUMENT_FRAGMENT_NODE:
18
+ return {
19
+ name: "#document-fragment",
20
+ children: [...node.childNodes]
21
+ .filter((child) => !isWhitespaceOnly(child))
22
+ .map(domNodeToObject),
23
+ };
24
+
25
+ case ELEMENT_NODE: {
26
+ const attributes = Object.fromEntries(
27
+ [...node.attributes].map((attr) => [attr.name, attr.value]),
28
+ );
29
+
30
+ const relevantChildren = [...node.childNodes].filter(
31
+ (child) =>
32
+ (child.nodeType === ELEMENT_NODE ||
33
+ child.nodeType === TEXT_NODE ||
34
+ child.nodeType === CDATA_SECTION_NODE) &&
35
+ !isWhitespaceOnly(child),
36
+ );
37
+
38
+ const onlyText = relevantChildren.every(
39
+ (child) =>
40
+ child.nodeType === TEXT_NODE || child.nodeType === CDATA_SECTION_NODE,
41
+ );
42
+
43
+ const result = {
44
+ name: node.localName,
45
+ };
46
+ if (Object.keys(attributes).length > 0) {
47
+ result.attributes = attributes;
48
+ }
49
+ if (onlyText) {
50
+ const text = relevantChildren
51
+ .map((child) => collapseWhitespace(child.nodeValue ?? ""))
52
+ .join("")
53
+ .trim();
54
+ if (text.length > 0) {
55
+ result.text = text;
56
+ }
57
+ } else if (relevantChildren.length > 0) {
58
+ result.children = relevantChildren.map(domNodeToObject);
59
+ }
60
+
61
+ return result;
62
+ }
63
+
64
+ case TEXT_NODE:
65
+ return {
66
+ name: "#text",
67
+ text: collapseWhitespace(node.nodeValue ?? ""),
68
+ };
69
+
70
+ case CDATA_SECTION_NODE:
71
+ return {
72
+ name: "#cdata-section",
73
+ text: collapseWhitespace(node.nodeValue ?? ""),
74
+ };
75
+
76
+ default:
77
+ return {
78
+ name: `#node-${node.nodeType}`,
79
+ };
80
+ }
81
+ }
82
+
83
+ // Collapse leading or trailing whitespace characters to a single space
84
+ function collapseWhitespace(str) {
85
+ return str.replace(/^\s+/, " ").replace(/\s+$/, " ");
86
+ }
87
+
88
+ function isWhitespaceOnly(node) {
89
+ return (
90
+ (node.nodeType === TEXT_NODE || node.nodeType === CDATA_SECTION_NODE) &&
91
+ node.nodeValue.trim() === ""
92
+ );
93
+ }
@@ -0,0 +1,22 @@
1
+ import { args } from "@weborigami/async-tree";
2
+ import loadJsDom from "../common/loadJsDom.js";
3
+ import domNodeToObject from "./domNodeToObject.js";
4
+
5
+ /**
6
+ * Return the DOM structure for the given HTML as a plain object.
7
+ *
8
+ * @param {import("@weborigami/async-tree").Stringlike} html
9
+ */
10
+ export default async function htmlParse(html) {
11
+ html = args.stringlike(html, "Origami.htmlParse");
12
+ const { JSDOM } = await loadJsDom();
13
+ const dom = JSDOM.fragment(html);
14
+ let object = domNodeToObject(dom);
15
+ if (
16
+ (object.name === "#document" || object.name === "#document-fragment") &&
17
+ object.children.length === 1
18
+ ) {
19
+ object = object.children[0];
20
+ }
21
+ return object;
22
+ }
@@ -22,18 +22,29 @@ export default async function mdOutline(input) {
22
22
  const outline = {};
23
23
  const stack = [];
24
24
  let sectionText = "";
25
+ let sectionTextTrimmed = null;
25
26
  /** @type {any} */
26
27
  let current = outline;
27
28
  for (const token of tokens) {
28
29
  if (token.type === "heading") {
29
30
  // Current section text gets added as content for the current node.
30
- if (sectionText) {
31
- current._text = sectionText.trim();
31
+ sectionTextTrimmed = sectionText.trim();
32
+ if (sectionTextTrimmed) {
33
+ current._text = sectionTextTrimmed;
32
34
  sectionText = "";
33
35
  }
34
36
 
35
- // Pop the stack to find the right level for this heading
36
37
  const { depth, text: headingText } = token;
38
+
39
+ // Did we skip a heading level? If so, create `_skip<n>` nodes
40
+ while (stack.length < depth - 1) {
41
+ const skipNode = {};
42
+ current[`_skip${stack.length + 1}`] = skipNode;
43
+ stack.push(current);
44
+ current = skipNode;
45
+ }
46
+
47
+ // Pop the stack to find the right level for this heading
37
48
  while (stack.length >= depth) {
38
49
  current = stack.pop();
39
50
  consolidateText(current);
@@ -51,8 +62,9 @@ export default async function mdOutline(input) {
51
62
  }
52
63
 
53
64
  // Any remaining section text gets added as content for the current node.
54
- if (sectionText) {
55
- current._text = sectionText.trim();
65
+ sectionTextTrimmed = sectionText.trim();
66
+ if (sectionTextTrimmed) {
67
+ current._text = sectionTextTrimmed;
56
68
  current = stack.pop();
57
69
  if (current) {
58
70
  consolidateText(current);
@@ -4,8 +4,8 @@ export { default as basename } from "./basename.js";
4
4
  export { default as csv } from "./csv.js";
5
5
  export { default as document } from "./document.js";
6
6
  export { default as fetch } from "./fetch.js";
7
- export { default as htmlDom } from "./htmlDom.js";
8
7
  export { default as htmlEscape } from "./htmlEscape.js";
8
+ export { default as htmlParse } from "./htmlParse.js";
9
9
  export { default as format } from "./image/format.js";
10
10
  export * as image from "./image/image.js";
11
11
  export { default as resize } from "./image/resize.js";
@@ -34,5 +34,6 @@ export { default as static } from "./static.js";
34
34
  export { default as string } from "./string.js";
35
35
  export { default as tsv } from "./tsv.js";
36
36
  export { default as unpack } from "./unpack.js";
37
+ export { default as xmlParse } from "./xmlParse.js";
37
38
  export { default as yaml } from "./yaml.js";
38
39
  export { default as yamlParse } from "./yamlParse.js";
@@ -0,0 +1,33 @@
1
+ import { args } from "@weborigami/async-tree";
2
+ import loadJsDom from "../common/loadJsDom.js";
3
+ import domNodeToObject from "./domNodeToObject.js";
4
+
5
+ let parser;
6
+
7
+ /**
8
+ * Return the DOM for the given XML as a plain object.
9
+ *
10
+ * @param {import("@weborigami/async-tree").Stringlike} xml
11
+ */
12
+ export default async function xmlParse(xml) {
13
+ xml = args.stringlike(xml, "Origami.xmlParse");
14
+ const parser = await getParser();
15
+ const dom = parser.parseFromString(xml, "application/xml");
16
+ let object = domNodeToObject(dom);
17
+ if (
18
+ (object.name === "#document" || object.name === "#document-fragment") &&
19
+ object.children.length === 1
20
+ ) {
21
+ object = object.children[0];
22
+ }
23
+ return object;
24
+ }
25
+
26
+ async function getParser() {
27
+ if (!parser) {
28
+ const { JSDOM } = await loadJsDom();
29
+ const dom = new JSDOM();
30
+ parser = new dom.window.DOMParser();
31
+ }
32
+ return parser;
33
+ }
@@ -88,7 +88,14 @@ export async function handleRequest(request, response, map) {
88
88
 
89
89
  export function keysFromUrl(url) {
90
90
  const encodedKeys = keysFromPath(url.pathname);
91
- const keys = encodedKeys.map((key) => decodeURIComponent(key));
91
+ // Decode the keys, but stop decoding if we encounter an Origami debugger command
92
+ let foundCommand = false;
93
+ const keys = encodedKeys.map((key) => {
94
+ if (key.startsWith("!")) {
95
+ foundCommand = true;
96
+ }
97
+ return foundCommand ? key : decodeURIComponent(key);
98
+ });
92
99
 
93
100
  // If the keys array is empty (the path was just a trailing slash) or if the
94
101
  // path ended with a slash, add "index.html" to the end of the keys.
@@ -104,12 +111,17 @@ export function keysFromUrl(url) {
104
111
  * https.createServer calls, letting you serve an async tree as a set of pages.
105
112
  *
106
113
  * @typedef {import("@weborigami/async-tree").Maplike} Maplike
114
+ * @param {object} options
115
+ * @param {boolean} [options.quiet] If true, suppresses logging of incoming requests.
107
116
  * @param {Maplike} maplike
108
117
  */
109
- export function requestListener(maplike) {
118
+ export function requestListener(maplike, options = {}) {
119
+ const quiet = options.quiet ?? false;
110
120
  const tree = Tree.from(maplike);
111
121
  return async function (request, response) {
112
- console.log(decodeURI(request.url));
122
+ if (!quiet) {
123
+ console.log(decodeURI(request.url));
124
+ }
113
125
  const handled = await handleRequest(request, response, tree);
114
126
  if (!handled) {
115
127
  // Not found, return a 404.
@@ -1,14 +0,0 @@
1
- import { args } from "@weborigami/async-tree";
2
- import loadJsDom from "../common/loadJsDom.js";
3
-
4
- /**
5
- * Return the DOM for the given HTML string.
6
- *
7
- * @param {import("@weborigami/async-tree").Stringlike} html
8
- */
9
- export default async function htmlDom(html) {
10
- html = args.stringlike(html, "Origami.htmlDom");
11
- const { JSDOM } = await loadJsDom();
12
- const dom = JSDOM.fragment(html);
13
- return dom;
14
- }