@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.
@@ -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
- * @param {import("@weborigami/async-tree").Maplike} maplike
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(maplike) {
28
- const source = Tree.from(maplike, { deep: true });
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
- // The tree doesn't have the key; try the defaults.
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 (Tree.isMaplike(value) && !Tree.isMap(value)) {
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
- if (Tree.isMap(value)) {
63
- // Ensure this transform is applied to any map result
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 command as a global or Dev command
104
- const command = globals[commandName] ?? globals.Dev?.[commandName];
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 {string} expression
18
- * @param {string} parentPath
17
+ * @param {Object} options
18
+ * @param {string} options.expression
19
+ * @param {string} options.parentPath
19
20
  */
20
- export default async function expressionTree(expression, parentPath) {
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
+ }