@weborigami/origami 0.0.66-beta.1 → 0.0.66-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,3 +1,5 @@
1
+ import { trailingSlash } from "@weborigami/async-tree";
2
+
1
3
  export default function PathTransform(Base) {
2
4
  return class Path extends Base {
3
5
  async get(key) {
@@ -6,7 +8,7 @@ export default function PathTransform(Base) {
6
8
  // @ts-ignore
7
9
  const path = this[PathTransform.pathKey]
8
10
  ? // @ts-ignore
9
- `${this[PathTransform.pathKey]}/${key}`
11
+ `${trailingSlash.add(this[PathTransform.pathKey])}${key}`
10
12
  : key;
11
13
  value[PathTransform.pathKey] = path;
12
14
  }
@@ -72,6 +72,7 @@ export { default as paginateFn } from "../src/builtins/@paginateFn.js";
72
72
  export { default as parent } from "../src/builtins/@parent.js";
73
73
  export { default as perf } from "../src/builtins/@perf.js";
74
74
  export { default as plain } from "../src/builtins/@plain.js";
75
+ export { default as post } from "../src/builtins/@post.js";
75
76
  export { default as project } from "../src/builtins/@project.js";
76
77
  export { default as redirect } from "../src/builtins/@redirect.js";
77
78
  export { default as regexMatch } from "../src/builtins/@regexMatch.js";
@@ -84,6 +85,7 @@ export { default as setDeep } from "../src/builtins/@setDeep.js";
84
85
  export { default as shell } from "../src/builtins/@shell.js";
85
86
  export { default as shuffle } from "../src/builtins/@shuffle.js";
86
87
  export { default as sitemap } from "../src/builtins/@sitemap.js";
88
+ export * from "../src/builtins/@slash.js";
87
89
  export { default as slug } from "../src/builtins/@slug.js";
88
90
  export { default as sort } from "../src/builtins/@sort.js";
89
91
  export { default as sortFn } from "../src/builtins/@sortFn.js";
@@ -139,4 +141,5 @@ export { default as origamiHighlightDefinition } from "../src/misc/origamiHighli
139
141
  export { default as treeDot } from "../src/misc/treeDot.js";
140
142
  export { default as constructResponse } from "../src/server/constructResponse.js";
141
143
  export * from "../src/server/mediaTypes.js";
144
+ export { default as parsePostData } from "../src/server/parsePostData.js";
142
145
  export * from "../src/server/server.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@weborigami/origami",
3
- "version": "0.0.66-beta.1",
3
+ "version": "0.0.66-beta.2",
4
4
  "description": "Web Origami language, CLI, framework, and server",
5
5
  "type": "module",
6
6
  "repository": {
@@ -13,17 +13,17 @@
13
13
  "main": "./exports/exports.js",
14
14
  "types": "./index.ts",
15
15
  "devDependencies": {
16
- "@types/node": "22.5.4",
17
- "typescript": "5.5.4"
16
+ "@types/node": "22.7.4",
17
+ "typescript": "5.6.2"
18
18
  },
19
19
  "dependencies": {
20
- "@weborigami/async-tree": "0.0.66-beta.1",
21
- "@weborigami/language": "0.0.66-beta.1",
22
- "@weborigami/types": "0.0.66-beta.1",
20
+ "@weborigami/async-tree": "0.0.66-beta.2",
21
+ "@weborigami/language": "0.0.66-beta.2",
22
+ "@weborigami/types": "0.0.66-beta.2",
23
23
  "exif-parser": "0.1.12",
24
24
  "graphviz-wasm": "3.0.2",
25
25
  "highlight.js": "11.10.0",
26
- "marked": "14.1.1",
26
+ "marked": "14.1.2",
27
27
  "marked-gfm-heading-id": "4.1.0",
28
28
  "marked-highlight": "2.1.4",
29
29
  "marked-smartypants": "1.1.8",
@@ -4,6 +4,7 @@ import {
4
4
  deepMerge,
5
5
  isPlainObject,
6
6
  keysFromPath,
7
+ trailingSlash,
7
8
  } from "@weborigami/async-tree";
8
9
  import { InvokeFunctionsTransform, extname } from "@weborigami/language";
9
10
  import * as utilities from "../common/utilities.js";
@@ -75,7 +76,7 @@ export default async function crawl(treelike, baseHref) {
75
76
  for (const resourcePath of resourcePaths) {
76
77
  const resourceKeys = adjustKeys(keysFromPath(resourcePath));
77
78
  const fn = () => {
78
- return traverse(tree, ...resourceKeys);
79
+ return Tree.traverse(tree, ...resourceKeys);
79
80
  };
80
81
  addValueToObject(resources, resourceKeys, fn);
81
82
  }
@@ -100,18 +101,19 @@ export default async function crawl(treelike, baseHref) {
100
101
  }
101
102
 
102
103
  // For indexing and storage purposes, treat a path that ends in a trailing slash
103
- // (or the dot we use to seed the queue) as if it ends in index.html.
104
+ // as if it ends in index.html.
104
105
  function adjustKeys(keys) {
105
- const adjustedKeys = keys.slice();
106
- if (adjustedKeys.at(-1) === "") {
107
- adjustedKeys[adjustedKeys.length - 1] = "index.html";
106
+ if (keys.length > 0 && !trailingSlash.has(keys.at(-1))) {
107
+ return keys;
108
108
  }
109
+ const adjustedKeys = keys.slice();
110
+ adjustedKeys.push("index.html");
109
111
  return adjustedKeys;
110
112
  }
111
113
 
112
114
  function addValueToObject(object, keys, value) {
113
115
  for (let i = 0, current = object; i < keys.length; i++) {
114
- const key = keys[i];
116
+ const key = trailingSlash.remove(keys[i]);
115
117
  if (i === keys.length - 1) {
116
118
  // Write out value
117
119
  if (isPlainObject(current[key])) {
@@ -150,7 +152,7 @@ async function* crawlPaths(tree, baseUrl) {
150
152
  const promisesForPaths = {};
151
153
 
152
154
  // Seed the promise dictionary with robots.txt and the root path.
153
- const initialPaths = ["/robots.txt", ""];
155
+ const initialPaths = ["/robots.txt", "/"];
154
156
  initialPaths.forEach((path) => {
155
157
  promisesForPaths[path] = processPath(tree, path, baseUrl);
156
158
  });
@@ -468,14 +470,13 @@ async function processPath(tree, path, baseUrl) {
468
470
  }
469
471
 
470
472
  // Convert path to keys
471
- /** @type {any[]} */
472
- let keys = path === "" ? [""] : keysFromPath(path);
473
+ const keys = keysFromPath(path);
473
474
 
474
475
  // Traverse tree to get value.
475
- let value = await traverse(tree, ...keys);
476
+ let value = await Tree.traverse(tree, ...keys);
476
477
  if (Tree.isAsyncTree(value)) {
477
478
  // Path is actually a directory; see if it has an index.html
478
- value = await traverse(value, "index.html");
479
+ value = await Tree.traverse(value, "index.html");
479
480
  }
480
481
 
481
482
  const adjustedKeys = adjustKeys(keys);
@@ -508,18 +509,5 @@ async function processPath(tree, path, baseUrl) {
508
509
  };
509
510
  }
510
511
 
511
- async function traverse(tree, ...keys) {
512
- if (tree.resolve && keys.length > 1) {
513
- // Tree like SiteTree that supports resolve() method
514
- const lastKey = keys.pop();
515
- const path = keys.join("/");
516
- const resolved = tree.resolve(path);
517
- return resolved.get(lastKey);
518
- } else {
519
- // Regular async tree
520
- return Tree.traverse(tree, ...keys);
521
- }
522
- }
523
-
524
512
  crawl.usage = `@crawl <tree>\tCrawl a tree`;
525
513
  crawl.documentation = "https://weborigami.org/language/@crawl.html";
@@ -39,8 +39,10 @@ function DebugTransform(Base) {
39
39
  const parent = this;
40
40
 
41
41
  // Since this transform is for diagnostic purposes, cast any treelike
42
- // result to a tree so we can debug the result too.
43
- if (Tree.isTreelike(value)) {
42
+ // result to a tree so we can debug the result too. (Don't do this for
43
+ // functions, as that can be undesirable, e.g., when writing functions
44
+ // that handle POST requests.)
45
+ if (Tree.isTreelike(value) && typeof value !== "function") {
44
46
  value = Tree.from(value, { parent });
45
47
  if (!isTransformApplied(DebugTransform, value)) {
46
48
  value = transformObject(DebugTransform, value);
@@ -1,8 +1,9 @@
1
- import { Tree } from "@weborigami/async-tree";
1
+ import { trailingSlash, Tree } from "@weborigami/async-tree";
2
2
  import getTreeArgument from "../misc/getTreeArgument.js";
3
3
 
4
4
  /**
5
- * Return the source nodes of the tree: the nodes with children.
5
+ * Return the interior nodes of the tree. This relies on subtree keys having
6
+ * trailing slashes.
6
7
  *
7
8
  * @typedef {import("@weborigami/types").AsyncTree} AsyncTree
8
9
  * @typedef {import("@weborigami/async-tree").Treelike} Treelike
@@ -19,12 +20,8 @@ export default async function inners(treelike) {
19
20
  },
20
21
 
21
22
  async keys() {
22
- const subtreeKeys = [];
23
- for (const key of await tree.keys()) {
24
- if (await Tree.isKeyForSubtree(tree, key)) {
25
- subtreeKeys.push(key);
26
- }
27
- }
23
+ const keys = [...(await tree.keys())];
24
+ const subtreeKeys = keys.filter(trailingSlash.has);
28
25
  return subtreeKeys;
29
26
  },
30
27
  };
@@ -32,5 +29,5 @@ export default async function inners(treelike) {
32
29
  return result;
33
30
  }
34
31
 
35
- inners.usage = `@inners <tree>\tThe source nodes of the tree`;
32
+ inners.usage = `@inners <tree>\tThe interior nodes of the tree`;
36
33
  inners.documentation = "https://weborigami.org/cli/builtins.html#inners";
@@ -91,7 +91,7 @@ export default function mapFnBuiltin(operation) {
91
91
  );
92
92
  return resultKey;
93
93
  }
94
- const keyFns = cachedKeyFunctions(scopedKeyFn);
94
+ const keyFns = cachedKeyFunctions(scopedKeyFn, deep);
95
95
  extendedKeyFn = keyFns.key;
96
96
  extendedInverseKeyFn = keyFns.inverseKey;
97
97
  } else {
@@ -0,0 +1,45 @@
1
+ import {
2
+ isStringLike,
3
+ isUnpackable,
4
+ toPlainValue,
5
+ toString,
6
+ Tree,
7
+ } from "@weborigami/async-tree";
8
+
9
+ /**
10
+ * @this {import("@weborigami/types").AsyncTree|null}
11
+ * @param {string} url
12
+ * @param {any} data
13
+ */
14
+ export default async function post(url, data) {
15
+ let body;
16
+ let headers;
17
+ if (isUnpackable(data)) {
18
+ data = await data.unpack();
19
+ }
20
+ if (Tree.isTreelike(data)) {
21
+ const value = await toPlainValue(data);
22
+ body = JSON.stringify(value);
23
+ headers = {
24
+ "Content-Type": "application/json",
25
+ };
26
+ } else if (isStringLike(data)) {
27
+ body = toString(data);
28
+ headers = {
29
+ "Content-Type": "text/plain",
30
+ };
31
+ } else {
32
+ body = data;
33
+ }
34
+ const response = await fetch(url, {
35
+ method: "POST",
36
+ body,
37
+ headers,
38
+ });
39
+ if (!response.ok) {
40
+ throw new Error(
41
+ `Failed to POST to ${url}. Error ${response.status}: ${response.statusText}`
42
+ );
43
+ }
44
+ return response.arrayBuffer();
45
+ }
@@ -26,9 +26,6 @@ export default async function serve(treelike, port) {
26
26
  let tree;
27
27
  if (treelike) {
28
28
  tree = Tree.from(treelike, { parent: this });
29
-
30
- // TODO: Instead of applying ExplorableSiteTransform, apply a transform
31
- // that just maps the empty string to index.html.
32
29
  if (!isTransformApplied(ExplorableSiteTransform, tree)) {
33
30
  tree = transformObject(ExplorableSiteTransform, tree);
34
31
  }
@@ -0,0 +1 @@
1
+ export { trailingSlash as default } from "@weborigami/async-tree";
@@ -1,4 +1,5 @@
1
1
  import { Tree } from "@weborigami/async-tree";
2
+ import { formatError } from "@weborigami/language";
2
3
  import ConstantTree from "../common/ConstantTree.js";
3
4
  import getTreeArgument from "../misc/getTreeArgument.js";
4
5
 
@@ -48,8 +49,8 @@ async function evaluateTree(parent, fn) {
48
49
  let result;
49
50
  try {
50
51
  result = await fn.call(parent);
51
- } catch (error) {
52
- message = messageForError(error);
52
+ } catch (/** @type {any} */ error) {
53
+ message = formatError(error);
53
54
  }
54
55
  tree = result ? Tree.from(result, { parent }) : undefined;
55
56
  if (tree) {
@@ -64,29 +65,19 @@ async function evaluateTree(parent, fn) {
64
65
  return tree;
65
66
  }
66
67
 
67
- function messageForError(error) {
68
- let message = "";
69
- // Work up to the root cause, displaying intermediate messages as we go up.
70
- while (error.cause) {
71
- message += error.message + `\n`;
72
- error = error.cause;
73
- }
74
- if (error.name) {
75
- message += `${error.name}: `;
76
- }
77
- message += error.message;
78
- return message;
79
- }
80
-
81
68
  // Update an indirect pointer to a target.
82
69
  function updateIndirectPointer(indirect, target) {
83
70
  // Clean the pointer of any named properties or symbols that have been set
84
71
  // directly on it.
85
- for (const key of Object.getOwnPropertyNames(indirect)) {
86
- delete indirect[key];
87
- }
88
- for (const key of Object.getOwnPropertySymbols(indirect)) {
89
- delete indirect[key];
72
+ try {
73
+ for (const key of Object.getOwnPropertyNames(indirect)) {
74
+ delete indirect[key];
75
+ }
76
+ for (const key of Object.getOwnPropertySymbols(indirect)) {
77
+ delete indirect[key];
78
+ }
79
+ } catch {
80
+ // Ignore errors.
90
81
  }
91
82
 
92
83
  Object.setPrototypeOf(indirect, target);
@@ -1,3 +1,4 @@
1
+ import { trailingSlash } from "@weborigami/async-tree";
1
2
  import path from "node:path";
2
3
 
3
4
  /**
@@ -18,6 +19,7 @@ export default function CommandsModulesTransform(Base) {
18
19
  }
19
20
 
20
21
  // See if we have a JS module for the requested key.
22
+ key = trailingSlash.remove(key);
21
23
  if (key === undefined || key.endsWith?.(".js")) {
22
24
  return undefined;
23
25
  }
@@ -25,11 +25,6 @@ import { isTransformApplied, transformObject } from "../common/utilities.js";
25
25
  export default function ExplorableSiteTransform(Base) {
26
26
  return class ExplorableSite extends Base {
27
27
  async get(key) {
28
- // The empty string key represents "index.html".
29
- if (key === "") {
30
- key = "index.html";
31
- }
32
-
33
28
  // Ask the tree if it has the key.
34
29
  let value = await super.get(key);
35
30
 
@@ -49,11 +44,6 @@ export default function ExplorableSiteTransform(Base) {
49
44
  if (!isTransformApplied(ExplorableSiteTransform, value)) {
50
45
  value = transformObject(ExplorableSiteTransform, value);
51
46
  }
52
-
53
- if (key.endsWith?.("/")) {
54
- // Instead of return the tree directly, return an index for it.
55
- value = await index.call(this, value);
56
- }
57
47
  } else if (value?.unpack) {
58
48
  // If the value isn't a tree, but has a tree attached via an `unpack`
59
49
  // method, wrap the unpack method to add this transform.
@@ -61,7 +51,8 @@ export default function ExplorableSiteTransform(Base) {
61
51
  const parent = this;
62
52
  value.unpack = async () => {
63
53
  const content = await original();
64
- if (!Tree.isTraversable(content)) {
54
+ // See function notes at @debug
55
+ if (!Tree.isTraversable(content) || typeof content === "function") {
65
56
  return content;
66
57
  }
67
58
  /** @type {any} */
@@ -77,5 +68,11 @@ export default function ExplorableSiteTransform(Base) {
77
68
  }
78
69
  return value;
79
70
  }
71
+
72
+ // If this value is given to the server, the server will call this pack()
73
+ // method. We respond with the index page.
74
+ async pack() {
75
+ return this.get("index.html");
76
+ }
80
77
  };
81
78
  }
@@ -1,4 +1,4 @@
1
- import { Tree } from "@weborigami/async-tree";
1
+ import { trailingSlash, Tree } from "@weborigami/async-tree";
2
2
 
3
3
  /**
4
4
  * @typedef {import("@weborigami/types").AsyncTree} AsyncTree
@@ -40,7 +40,7 @@ export default class FilterTree {
40
40
  // must be a tree too.
41
41
  const match =
42
42
  (!isFilterValueTree && filterValue) ||
43
- (isFilterValueTree && (await Tree.isKeyForSubtree(this.tree, key)));
43
+ (isFilterValueTree && trailingSlash.has(key));
44
44
  if (match) {
45
45
  keys.add(key);
46
46
  }
@@ -1,10 +1,4 @@
1
- import {
2
- DeepObjectTree,
3
- ObjectTree,
4
- Tree,
5
- isPlainObject,
6
- merge,
7
- } from "@weborigami/async-tree";
1
+ import { ObjectTree, Tree, merge, trailingSlash } from "@weborigami/async-tree";
8
2
 
9
3
  const globstar = "**";
10
4
 
@@ -14,15 +8,17 @@ const globstar = "**";
14
8
  */
15
9
  export default class GlobTree {
16
10
  constructor(globs) {
17
- this.globs = isPlainObject(globs)
18
- ? new DeepObjectTree(globs)
19
- : Tree.from(globs);
11
+ this.globs = Tree.from(globs, { deep: true });
20
12
  }
21
13
 
22
14
  async get(key) {
23
15
  if (typeof key !== "string") {
24
16
  return undefined;
25
17
  }
18
+
19
+ // Remove trailing slash if it exists
20
+ key = trailingSlash.remove(key);
21
+
26
22
  let value = await matchGlobs(this.globs, key);
27
23
  if (Tree.isAsyncTree(value)) {
28
24
  value = Reflect.construct(this.constructor, [value]);
@@ -35,8 +31,8 @@ export default class GlobTree {
35
31
  }
36
32
  }
37
33
 
34
+ // Convert the glob to a regular expression
38
35
  function matchGlob(glob, text) {
39
- // Convert the glob to a regular expression
40
36
  const regexText = glob
41
37
  // Escape special regex characters
42
38
  .replace(/[+?^${}()|\[\]\\]/g, "\\$&")
@@ -49,10 +45,15 @@ function matchGlob(glob, text) {
49
45
 
50
46
  async function matchGlobs(globs, text) {
51
47
  let value;
52
- for (const glob of await globs.keys()) {
48
+ for (let glob of await globs.keys()) {
53
49
  if (typeof glob !== "string") {
54
50
  continue;
55
- } else if (glob !== globstar && matchGlob(glob, text)) {
51
+ }
52
+
53
+ // Remove trailing slash if it exists
54
+ glob = trailingSlash.remove(glob);
55
+
56
+ if (glob !== globstar && matchGlob(glob, text)) {
56
57
  value = await globs.get(glob);
57
58
  if (value !== undefined) {
58
59
  break;
@@ -3,6 +3,7 @@ import {
3
3
  isPlainObject,
4
4
  isStringLike,
5
5
  toString,
6
+ trailingSlash,
6
7
  } from "@weborigami/async-tree";
7
8
  import * as serialize from "../common/serialize.js";
8
9
  import { keySymbol } from "../common/utilities.js";
@@ -49,7 +50,7 @@ async function statements(tree, nodePath, nodeLabel, options) {
49
50
  // Draw edges and collect labels for the nodes they lead to.
50
51
  let nodes = new Map();
51
52
  for (const key of await tree.keys()) {
52
- const destPath = nodePath ? `${nodePath}/${key}` : key;
53
+ const destPath = nodePath ? `${trailingSlash.add(nodePath)}${key}` : key;
53
54
  const arc = ` "${nodePath}" -> "${destPath}" [label="${key}"];`;
54
55
  result.push(arc);
55
56
 
@@ -163,7 +164,7 @@ async function statements(tree, nodePath, nodeLabel, options) {
163
164
  const label = `label="${icon}${text}"`;
164
165
  const color = node.isError ? `; color="red"` : "";
165
166
  const fill = node.isError ? `; fillcolor="#FFF4F4"` : "";
166
- const destPath = nodePath ? `${nodePath}/${key}` : key;
167
+ const destPath = nodePath ? `${trailingSlash.add(nodePath)}${key}` : key;
167
168
  const url = createLinks ? `; URL="${destPath}"` : "";
168
169
  result.push(` "${destPath}" [${label}${color}${fill}${url}];`);
169
170
  }
@@ -32,16 +32,6 @@ export default async function constructResponse(request, resource) {
32
32
  // Determine media type, what data we'll send, and encoding.
33
33
  const url = new URL(request.url ?? "", `https://${request.headers.host}`);
34
34
 
35
- let mediaType;
36
- if (resource.mediaType) {
37
- // Resource indicates its own media type.
38
- mediaType = resource.mediaType;
39
- } else {
40
- // Infer expected media type from file extension on request URL.
41
- const extension = extname(url.pathname).toLowerCase();
42
- mediaType = extension ? mediaTypeForExtension[extension] : undefined;
43
- }
44
-
45
35
  if (!url.pathname.endsWith("/") && Tree.isTreelike(resource)) {
46
36
  // Treelike resource: redirect to its index page.
47
37
  const Location = `${url.pathname}/`;
@@ -57,6 +47,16 @@ export default async function constructResponse(request, resource) {
57
47
  resource = await resource.pack();
58
48
  }
59
49
 
50
+ let mediaType;
51
+ if (resource.mediaType) {
52
+ // Resource indicates its own media type.
53
+ mediaType = resource.mediaType;
54
+ } else {
55
+ // Infer expected media type from file extension on request URL.
56
+ const extension = extname(url.pathname).toLowerCase();
57
+ mediaType = extension ? mediaTypeForExtension[extension] : undefined;
58
+ }
59
+
60
60
  if (
61
61
  (mediaType === "application/json" || mediaType === "text/yaml") &&
62
62
  !isStringLike(resource)
@@ -0,0 +1,35 @@
1
+ import { toString } from "@weborigami/async-tree";
2
+
3
+ export default async function parsePostData(request) {
4
+ const data = await getPostData(request);
5
+ const type = request.headers["content-type"];
6
+ switch (type) {
7
+ case "application/json":
8
+ return JSON.parse(data);
9
+
10
+ case "application/x-www-form-urlencoded":
11
+ const params = new URLSearchParams(data);
12
+ return Object.fromEntries(params);
13
+
14
+ case "text/plain":
15
+ return toString(data);
16
+
17
+ default:
18
+ return data;
19
+ }
20
+ }
21
+
22
+ function getPostData(request) {
23
+ return new Promise((resolve, reject) => {
24
+ let body = "";
25
+ request.on("data", (chunk) => {
26
+ body += chunk.toString();
27
+ });
28
+ request.on("end", () => {
29
+ resolve(body);
30
+ });
31
+ request.on("error", (error) => {
32
+ reject(error);
33
+ });
34
+ });
35
+ }
@@ -2,6 +2,7 @@ import { ObjectTree, Tree, keysFromPath } from "@weborigami/async-tree";
2
2
  import { formatError } from "@weborigami/language";
3
3
  import { ServerResponse } from "node:http";
4
4
  import constructResponse from "./constructResponse.js";
5
+ import parsePostData from "./parsePostData.js";
5
6
 
6
7
  /**
7
8
  * Copy a constructed response to a ServerResponse. Return true if the response
@@ -81,13 +82,16 @@ export async function handleRequest(request, response, tree) {
81
82
  ? extendTreeScopeWithParams(tree, url)
82
83
  : tree;
83
84
 
85
+ const data = request.method === "POST" ? await parsePostData(request) : null;
86
+
84
87
  // Ask the tree for the resource with those keys.
85
88
  let resource;
86
89
  try {
87
90
  resource = await Tree.traverse(extendedTree, ...keys);
88
91
  // If resource is a function, invoke to get the object we want to return.
92
+ // For a POST request, pass the data to the function.
89
93
  if (typeof resource === "function") {
90
- resource = await resource();
94
+ resource = data ? await resource(data) : await resource();
91
95
  }
92
96
  } catch (/** @type {any} */ error) {
93
97
  respondWithError(response, error);
@@ -111,8 +115,16 @@ function keysFromUrl(url) {
111
115
  const parts = url.pathname.split(/\/!/);
112
116
 
113
117
  // Split everything before the first command by slashes and decode those.
114
- const path = parts.shift();
118
+ let path = parts.shift();
119
+ if (parts.length > 0) {
120
+ // HACK: Add back trailing slash that was removed by split
121
+ path += "/";
122
+ }
115
123
  const pathKeys = keysFromPath(path).map((key) => decodeURIComponent(key));
124
+ if (parts.length > 0 && pathKeys.at(-1) === "") {
125
+ // HACK part 2: Remove empty string that was added for trailing slash
126
+ pathKeys.pop();
127
+ }
116
128
 
117
129
  // If there are no commands, and the path ends with a trailing slash, the
118
130
  // final key will be an empty string. Change that to "index.html".