@weborigami/origami 0.6.14 → 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 +3 -0
- package/package.json +4 -3
- package/src/common/findOpenPort.js +58 -0
- package/src/dev/debug2/debug2.js +30 -300
- package/src/dev/debug2/debugChild.js +11 -4
- package/src/dev/debug2/debugCommands.js +11 -0
- package/src/dev/debug2/debugParent.js +369 -0
- package/src/dev/debug2/debugTransform.js +37 -25
- package/src/dev/debug2/expressionTree.js +6 -3
- package/src/dev/debug2/oriEval.js +49 -0
- package/src/dev/help.yaml +12 -0
- package/src/handlers/origamiHandlers.js +1 -0
- package/src/handlers/xml_handler.js +23 -0
- package/src/origami/domNodeToObject.js +93 -0
- package/src/origami/htmlParse.js +22 -0
- package/src/origami/mdOutline.js +17 -5
- package/src/origami/origami.js +2 -1
- package/src/origami/xmlParse.js +33 -0
- package/src/server/server.js +30 -6
- package/src/origami/htmlDom.js +0 -14
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
import { fork } from "node:child_process";
|
|
2
|
+
import { EventEmitter } from "node:events";
|
|
3
|
+
import http from "node:http";
|
|
4
|
+
import { findOpenPort } from "../../common/findOpenPort.js";
|
|
5
|
+
|
|
6
|
+
const PUBLIC_HOST = "127.0.0.1";
|
|
7
|
+
|
|
8
|
+
// Module that loads the server in the child process
|
|
9
|
+
const childModuleUrl = new URL("./debugChild.js", import.meta.url);
|
|
10
|
+
|
|
11
|
+
// The public-facing server that proxies to the child process
|
|
12
|
+
let publicServer;
|
|
13
|
+
let publicOrigin;
|
|
14
|
+
|
|
15
|
+
// The active child process and port
|
|
16
|
+
/** @typedef {import("node:child_process").ChildProcess} ChildProcess */
|
|
17
|
+
/** @typedef {{ process: ChildProcess, port: number | null }} ChildInfo */
|
|
18
|
+
/** @type {ChildInfo | null} */
|
|
19
|
+
let activeChild = null;
|
|
20
|
+
|
|
21
|
+
// The most recently started child (may not be ready yet)
|
|
22
|
+
/** @type {ChildInfo | null} */
|
|
23
|
+
let pendingChild = null;
|
|
24
|
+
|
|
25
|
+
// Used to communicate errors to caller
|
|
26
|
+
let emitter = null;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Start a new debug parent server for the given Origami expression and runtime
|
|
30
|
+
* state.
|
|
31
|
+
*
|
|
32
|
+
* This function will start a child server that evaluates the given expression
|
|
33
|
+
* with the given parent path. This arrangement ensures the expression is
|
|
34
|
+
* evaluated in a clean Node context (not polluted by previous evaluations). The
|
|
35
|
+
* parent server proxies requests to the child server.
|
|
36
|
+
*
|
|
37
|
+
* The debug parent monitors the parent tree for changes, and restarts the child
|
|
38
|
+
* whenever files in the parent tree change.
|
|
39
|
+
*
|
|
40
|
+
* Supported `options`:
|
|
41
|
+
* - `expression` (required): the Origami expression to evaluate in the child
|
|
42
|
+
* process
|
|
43
|
+
* - `parentPath` (required): the path to the parent tree used for evaluation
|
|
44
|
+
*
|
|
45
|
+
* The returned `emitter` is an EventEmitter that emits "error" events when the
|
|
46
|
+
* child server encounters an Origami error while handling a request.
|
|
47
|
+
*
|
|
48
|
+
* @param {Object} options
|
|
49
|
+
* @param {string} options.expression
|
|
50
|
+
* @param {string} options.parentPath
|
|
51
|
+
* @param {number} [options.port]
|
|
52
|
+
* @param {boolean} [options.quiet]
|
|
53
|
+
*/
|
|
54
|
+
export default async function debugParent(options) {
|
|
55
|
+
const { parentPath } = options;
|
|
56
|
+
if (parentPath === undefined) {
|
|
57
|
+
throw new Error("Debugger couldn't work out the parent path.");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const port = options.port ?? (await findOpenPort());
|
|
61
|
+
publicOrigin = `http://${PUBLIC_HOST}:${port}`;
|
|
62
|
+
|
|
63
|
+
publicServer = http.createServer(proxyRequest);
|
|
64
|
+
await new Promise((resolve) =>
|
|
65
|
+
publicServer.listen(port, PUBLIC_HOST, resolve),
|
|
66
|
+
);
|
|
67
|
+
await startChild(options);
|
|
68
|
+
|
|
69
|
+
emitter = Object.assign(new EventEmitter(), {
|
|
70
|
+
close,
|
|
71
|
+
origin: publicOrigin,
|
|
72
|
+
reevaluate,
|
|
73
|
+
restart: () => startChild(options),
|
|
74
|
+
});
|
|
75
|
+
return emitter;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Gracefully stop the parent server and any active child server, giving the
|
|
80
|
+
* child a chance to finish any in-flight requests before exiting.
|
|
81
|
+
*/
|
|
82
|
+
async function close() {
|
|
83
|
+
// Stop accepting new connections and force-close any keep-alive connections
|
|
84
|
+
// so the close callback fires promptly.
|
|
85
|
+
const closed = new Promise((resolve) => publicServer.close(resolve));
|
|
86
|
+
publicServer.closeAllConnections();
|
|
87
|
+
await closed;
|
|
88
|
+
publicServer = null;
|
|
89
|
+
|
|
90
|
+
// Drain and stop any children concurrently
|
|
91
|
+
const children = [pendingChild?.process, activeChild?.process].filter(
|
|
92
|
+
/** @returns {child is ChildProcess} */
|
|
93
|
+
(child) => child !== undefined,
|
|
94
|
+
);
|
|
95
|
+
pendingChild = null;
|
|
96
|
+
activeChild = null;
|
|
97
|
+
await Promise.all(children.map(drainAndStopChild));
|
|
98
|
+
|
|
99
|
+
emitter.emit("close");
|
|
100
|
+
emitter.removeAllListeners();
|
|
101
|
+
emitter = null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Give a child process a chance to finish any in-flight requests before we kill
|
|
106
|
+
* it.
|
|
107
|
+
*
|
|
108
|
+
* @param {ChildProcess} childProcess
|
|
109
|
+
*/
|
|
110
|
+
async function drainAndStopChild(childProcess) {
|
|
111
|
+
if (childProcess.killed) {
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Ask it to drain first.
|
|
116
|
+
try {
|
|
117
|
+
childProcess.send({ type: "DRAIN" });
|
|
118
|
+
} catch {
|
|
119
|
+
// ignore
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const drained = new Promise((resolve) => {
|
|
123
|
+
const onMessage = (msg) => {
|
|
124
|
+
if (msg && typeof msg === "object" && msg.type === "DRAINED") {
|
|
125
|
+
cleanup(resolve);
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
const onExit = () => cleanup(resolve);
|
|
129
|
+
|
|
130
|
+
function cleanup(done) {
|
|
131
|
+
childProcess.off("message", onMessage);
|
|
132
|
+
childProcess.off("exit", onExit);
|
|
133
|
+
done();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
childProcess.on("message", onMessage);
|
|
137
|
+
childProcess.on("exit", onExit);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// Give it a short grace window to finish in-flight work.
|
|
141
|
+
const GRACE_MS = 1500;
|
|
142
|
+
await Promise.race([
|
|
143
|
+
drained,
|
|
144
|
+
new Promise((r) => setTimeout(r, GRACE_MS).unref()),
|
|
145
|
+
]);
|
|
146
|
+
|
|
147
|
+
if (!childProcess.killed) {
|
|
148
|
+
childProcess.kill("SIGTERM");
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Final escalation.
|
|
152
|
+
setTimeout(() => {
|
|
153
|
+
// Child should have exited by now, but if not kill it
|
|
154
|
+
if (!childProcess.killed) {
|
|
155
|
+
childProcess.kill("SIGKILL");
|
|
156
|
+
}
|
|
157
|
+
}, GRACE_MS).unref();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Proxy incoming requests to the active child server, or return a 503 if not
|
|
162
|
+
* ready.
|
|
163
|
+
*
|
|
164
|
+
* @param {import("node:http").IncomingMessage} request
|
|
165
|
+
* @param {import("node:http").ServerResponse} response
|
|
166
|
+
*/
|
|
167
|
+
function proxyRequest(request, response) {
|
|
168
|
+
if (!activeChild) {
|
|
169
|
+
response.statusCode = 503;
|
|
170
|
+
response.setHeader("content-type", "text/plain; charset=utf-8");
|
|
171
|
+
response.end("Dev server is starting…\n");
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const { port } = activeChild;
|
|
176
|
+
|
|
177
|
+
// Minimal hop-by-hop header stripping
|
|
178
|
+
const headers = { ...request.headers };
|
|
179
|
+
delete headers.connection;
|
|
180
|
+
delete headers["proxy-connection"];
|
|
181
|
+
delete headers["keep-alive"];
|
|
182
|
+
delete headers.te;
|
|
183
|
+
delete headers.trailer;
|
|
184
|
+
delete headers["transfer-encoding"];
|
|
185
|
+
delete headers.upgrade;
|
|
186
|
+
|
|
187
|
+
const upstreamRequest = http.request(
|
|
188
|
+
{
|
|
189
|
+
host: PUBLIC_HOST,
|
|
190
|
+
port,
|
|
191
|
+
method: request.method,
|
|
192
|
+
path: request.url,
|
|
193
|
+
headers,
|
|
194
|
+
},
|
|
195
|
+
(upstreamResponse) => {
|
|
196
|
+
const { statusCode } = upstreamResponse;
|
|
197
|
+
response.writeHead(
|
|
198
|
+
statusCode ?? 502,
|
|
199
|
+
upstreamResponse.statusMessage,
|
|
200
|
+
upstreamResponse.headers,
|
|
201
|
+
);
|
|
202
|
+
upstreamResponse.pipe(response);
|
|
203
|
+
|
|
204
|
+
// Let caller know about the Origami error messages
|
|
205
|
+
if (statusCode !== undefined && statusCode >= 500 && emitter) {
|
|
206
|
+
const rawHeader = upstreamResponse.headers["x-error-details"];
|
|
207
|
+
const raw = Array.isArray(rawHeader) ? rawHeader[0] : rawHeader;
|
|
208
|
+
const message = raw ? decodeURIComponent(raw) : undefined;
|
|
209
|
+
if (message) {
|
|
210
|
+
emitter.emit("origami-error", message);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
},
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
upstreamRequest.on("error", (err) => {
|
|
217
|
+
// Stop piping the request body
|
|
218
|
+
request.unpipe(upstreamRequest);
|
|
219
|
+
upstreamRequest.destroy();
|
|
220
|
+
|
|
221
|
+
// Only send error response if headers haven't been sent yet
|
|
222
|
+
if (!response.headersSent) {
|
|
223
|
+
response.statusCode = 502;
|
|
224
|
+
response.setHeader("content-type", "text/plain; charset=utf-8");
|
|
225
|
+
response.end(`Upstream error: ${err.message}\n`);
|
|
226
|
+
} else {
|
|
227
|
+
// Headers already sent, can't send error message - just close
|
|
228
|
+
response.destroy();
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
// Also handle errors on the incoming request
|
|
233
|
+
request.on("error", () => {
|
|
234
|
+
upstreamRequest.destroy();
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
request.pipe(upstreamRequest);
|
|
238
|
+
}
|
|
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
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Start a new child process.
|
|
266
|
+
*
|
|
267
|
+
* This will be a pending process until it sends a READY message, at which point
|
|
268
|
+
* it becomes active and any previous active child is drained and stopped.
|
|
269
|
+
*/
|
|
270
|
+
function startChild(options) {
|
|
271
|
+
const { expression, parentPath } = options;
|
|
272
|
+
const quiet = options.quiet ?? false;
|
|
273
|
+
|
|
274
|
+
// Start the child process, passing parent path via an environment variable.
|
|
275
|
+
/** @type {ChildProcess} */
|
|
276
|
+
let childProcess;
|
|
277
|
+
try {
|
|
278
|
+
childProcess = fork(childModuleUrl, [], {
|
|
279
|
+
stdio: ["inherit", "inherit", "inherit", "ipc"],
|
|
280
|
+
env: {
|
|
281
|
+
...process.env,
|
|
282
|
+
ORIGAMI_EXPRESSION: expression,
|
|
283
|
+
ORIGAMI_PARENT_PATH: parentPath,
|
|
284
|
+
ORIGAMI_QUIET: quiet ? "1" : "0",
|
|
285
|
+
},
|
|
286
|
+
});
|
|
287
|
+
} catch (error) {
|
|
288
|
+
throw new Error("Dev.debug2: failed to start child server:", {
|
|
289
|
+
cause: error,
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// This becomes the pending child immediately
|
|
294
|
+
pendingChild = { process: childProcess, port: null };
|
|
295
|
+
|
|
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"));
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
|
|
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
|
+
),
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
});
|
|
367
|
+
})
|
|
368
|
+
);
|
|
369
|
+
}
|
|
@@ -9,9 +9,9 @@ import {
|
|
|
9
9
|
scope,
|
|
10
10
|
trailingSlash,
|
|
11
11
|
} from "@weborigami/async-tree";
|
|
12
|
-
import { projectGlobals } from "@weborigami/language";
|
|
13
12
|
import indexPage from "../../origami/indexPage.js";
|
|
14
13
|
import yaml from "../../origami/yaml.js";
|
|
14
|
+
import * as debugCommands from "./debugCommands.js";
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
17
|
* Transform the given map-based tree to add debugging resources:
|
|
@@ -22,10 +22,30 @@ import yaml from "../../origami/yaml.js";
|
|
|
22
22
|
*
|
|
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
|
+
*
|
|
28
|
+
* @param {Maplike|Packed} input
|
|
26
29
|
*/
|
|
27
|
-
export default function debugTransform(
|
|
28
|
-
|
|
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
|
+
}
|
|
46
|
+
|
|
47
|
+
const source = Tree.from(input, { deep: true });
|
|
48
|
+
|
|
29
49
|
return Object.assign(new AsyncMap(), {
|
|
30
50
|
description: "debug resources",
|
|
31
51
|
|
|
@@ -34,7 +54,7 @@ export default function debugTransform(maplike) {
|
|
|
34
54
|
let value = await source.get(key);
|
|
35
55
|
|
|
36
56
|
if (value === undefined) {
|
|
37
|
-
//
|
|
57
|
+
// Try the defaults and commands
|
|
38
58
|
if (key === "index.html") {
|
|
39
59
|
// Generate an index page for this site
|
|
40
60
|
value = await indexPage(source);
|
|
@@ -54,28 +74,21 @@ export default function debugTransform(maplike) {
|
|
|
54
74
|
Tree.merge(object, {
|
|
55
75
|
"index.html": yamlText,
|
|
56
76
|
});
|
|
57
|
-
} else if (
|
|
77
|
+
} else if (
|
|
78
|
+
Tree.isMaplike(value) &&
|
|
79
|
+
!Tree.isMap(value) &&
|
|
80
|
+
typeof value !== "function"
|
|
81
|
+
) {
|
|
58
82
|
// Make it a map so we can debug it
|
|
59
83
|
value = Tree.from(value);
|
|
60
84
|
}
|
|
61
85
|
|
|
62
|
-
|
|
63
|
-
|
|
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) {
|
|
64
89
|
value = debugTransform(value);
|
|
65
|
-
} else if (value?.unpack) {
|
|
66
|
-
// If the value isn't a tree, but has a tree attached via an `unpack`
|
|
67
|
-
// method, wrap the unpack method to add this transform.
|
|
68
|
-
const original = value.unpack.bind(value);
|
|
69
|
-
value.unpack = async () => {
|
|
70
|
-
const content = await original();
|
|
71
|
-
if (!Tree.isTraversable(content) || typeof content === "function") {
|
|
72
|
-
return content;
|
|
73
|
-
}
|
|
74
|
-
/** @type {any} */
|
|
75
|
-
let tree = Tree.from(content);
|
|
76
|
-
return debugTransform(tree);
|
|
77
|
-
};
|
|
78
90
|
}
|
|
91
|
+
|
|
79
92
|
return value;
|
|
80
93
|
},
|
|
81
94
|
|
|
@@ -97,14 +110,13 @@ export default function debugTransform(maplike) {
|
|
|
97
110
|
|
|
98
111
|
async function invokeOrigamiCommand(tree, key) {
|
|
99
112
|
// Key is an Origami command; invoke it.
|
|
100
|
-
const globals = await projectGlobals(tree);
|
|
101
113
|
const commandName = trailingSlash.remove(key.slice(1).trim());
|
|
102
114
|
|
|
103
|
-
// Look for
|
|
104
|
-
const command =
|
|
115
|
+
// Look for the indicated command
|
|
116
|
+
const command = debugCommands[commandName];
|
|
105
117
|
let value;
|
|
106
118
|
if (command) {
|
|
107
|
-
value = await command(tree);
|
|
119
|
+
value = command instanceof Function ? await command(tree) : command;
|
|
108
120
|
} else {
|
|
109
121
|
// Look for command in scope
|
|
110
122
|
const parentScope = await scope(tree);
|
|
@@ -14,10 +14,13 @@ let version = 0;
|
|
|
14
14
|
* Evaluate the given expression using the indicated parent path to produce a
|
|
15
15
|
* resource tree, then transform that tree with debug resources and return it.
|
|
16
16
|
*
|
|
17
|
-
* @param {
|
|
18
|
-
* @param {string}
|
|
17
|
+
* @param {Object} options
|
|
18
|
+
* @param {string} options.expression
|
|
19
|
+
* @param {string} options.parentPath
|
|
19
20
|
*/
|
|
20
|
-
export default async function expressionTree(
|
|
21
|
+
export default async function expressionTree(options) {
|
|
22
|
+
const { expression, parentPath } = options;
|
|
23
|
+
|
|
21
24
|
const parent = new OrigamiFileMap(parentPath);
|
|
22
25
|
const globals = await projectGlobals(parent);
|
|
23
26
|
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { trailingSlash, Tree } from "@weborigami/async-tree";
|
|
2
|
+
import { evaluate, projectGlobals } from "@weborigami/language";
|
|
3
|
+
import debugTransform from "./debugTransform.js";
|
|
4
|
+
|
|
5
|
+
const mapParentToResult = new WeakMap();
|
|
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
|
+
*/
|
|
18
|
+
export default async function oriEval(parent) {
|
|
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;
|
|
25
|
+
}
|
|
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;
|
|
48
|
+
};
|
|
49
|
+
}
|
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
|
|
@@ -211,6 +217,9 @@ Tree:
|
|
|
211
217
|
deepValues:
|
|
212
218
|
args: (tree)
|
|
213
219
|
description: The in-order leaf values of the tree
|
|
220
|
+
deflatePaths:
|
|
221
|
+
args: (tree)
|
|
222
|
+
description: Convert a tree to a mapping of paths to values
|
|
214
223
|
delete:
|
|
215
224
|
args: (map, key)
|
|
216
225
|
description: Delete the value for the key from map
|
|
@@ -244,6 +253,9 @@ Tree:
|
|
|
244
253
|
indent:
|
|
245
254
|
args: "`…`"
|
|
246
255
|
description: Tagged template literal for normalizing indentation
|
|
256
|
+
inflatePaths:
|
|
257
|
+
args: (map)
|
|
258
|
+
description: Convert mapping of paths to values into a tree
|
|
247
259
|
inners:
|
|
248
260
|
args: (tree)
|
|
249
261
|
description: The tree's interior nodes
|
|
@@ -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
|
+
}
|