@weborigami/language 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.
@@ -13,6 +13,22 @@ export function annotate(parseResult, location) {
13
13
  return parseResult;
14
14
  }
15
15
 
16
+ // Return true if the code will generate an async object.
17
+ function isAsyncObject(code) {
18
+ if (!(code instanceof Array)) {
19
+ return false;
20
+ }
21
+ if (code[0] !== ops.object) {
22
+ return false;
23
+ }
24
+ // Are any of the properties getters?
25
+ const entries = code.slice(1);
26
+ const hasGetter = entries.some(([key, value]) => {
27
+ return value instanceof Array && value[0] === ops.getter;
28
+ });
29
+ return hasGetter;
30
+ }
31
+
16
32
  export function makeArray(entries) {
17
33
  let currentEntries = [];
18
34
  const spreads = [];
@@ -72,9 +88,14 @@ export function makeFunctionCall(target, chain, location) {
72
88
 
73
89
  // @ts-ignore
74
90
  fnCall =
75
- args[0] === ops.traverse
76
- ? [ops.traverse, value, ...args.slice(1)]
77
- : [value, ...args];
91
+ args[0] !== ops.traverse
92
+ ? // Function call
93
+ [value, ...args]
94
+ : args.length > 1
95
+ ? // Traverse
96
+ [ops.traverse, value, ...args.slice(1)]
97
+ : // Traverse without arguments equates to unpack
98
+ [ops.unpack, value];
78
99
 
79
100
  // Create a location spanning the newly-constructed function call.
80
101
  if (args instanceof Array) {
@@ -97,14 +118,28 @@ export function makeObject(entries, op) {
97
118
  let currentEntries = [];
98
119
  const spreads = [];
99
120
 
100
- for (const [key, value] of entries) {
121
+ for (let [key, value] of entries) {
101
122
  if (key === ops.spread) {
123
+ // Accumulate spread entry
102
124
  if (currentEntries.length > 0) {
103
125
  spreads.push([op, ...currentEntries]);
104
126
  currentEntries = [];
105
127
  }
106
128
  spreads.push(value);
107
129
  } else {
130
+ if (
131
+ value instanceof Array &&
132
+ value[0] === ops.getter &&
133
+ value[1] instanceof Array &&
134
+ value[1][0] === ops.primitive
135
+ ) {
136
+ // Simplify a getter for a primitive value to a regular property
137
+ value = value[1];
138
+ } else if (isAsyncObject(value)) {
139
+ // Add a trailing slash to key if value is an async object
140
+ key = key + "/";
141
+ }
142
+
108
143
  currentEntries.push([key, value]);
109
144
  }
110
145
  }
@@ -1,4 +1,4 @@
1
- import { attachHandlerIfApplicable } from "./extensions.js";
1
+ import { handleExtension } from "./extensions.js";
2
2
 
3
3
  /**
4
4
  * @typedef {import("@weborigami/types").AsyncTree} AsyncTree
@@ -10,9 +10,8 @@ import { attachHandlerIfApplicable } from "./extensions.js";
10
10
  export default function HandleExtensionsTransform(Base) {
11
11
  return class FileLoaders extends Base {
12
12
  async get(key) {
13
- let value = await super.get(key);
14
- value = attachHandlerIfApplicable(this, value, key);
15
- return value;
13
+ const value = await super.get(key);
14
+ return handleExtension(this, value, key);
16
15
  }
17
16
  };
18
17
  }
@@ -1,6 +1,7 @@
1
1
  import * as fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { pathToFileURL } from "node:url";
4
+ import { maybeOrigamiSourceCode } from "./formatError.js";
4
5
 
5
6
  /**
6
7
  * @typedef {import("@weborigami/types").AsyncTree} AsyncTree
@@ -25,21 +26,23 @@ export default function ImportModulesMixin(Base) {
25
26
  }
26
27
 
27
28
  // Does the module exist as a file?
28
- let stats;
29
29
  try {
30
- stats = await fs.stat(filePath);
30
+ await fs.stat(filePath);
31
31
  } catch (error) {
32
- // Ignore errors.
32
+ // File doesn't exist
33
+ return undefined;
33
34
  }
34
- if (stats) {
35
- // Module exists, but we can't load it. This is often due to a syntax
36
- // error in the target module, so we offer that as a hint.
37
- const message = `Error loading ${filePath}, possibly due to a syntax error.\n${error.message}`;
38
- throw new SyntaxError(message);
35
+
36
+ // Module exists, but we can't load it. Is the error internal?
37
+ if (maybeOrigamiSourceCode(error.message)) {
38
+ throw new Error(
39
+ `Internal Origami error loading ${filePath}\n${error.message}`
40
+ );
39
41
  }
40
42
 
41
- // Module doesn't exist.
42
- return undefined;
43
+ // Error may be a syntax error, so we offer that as a hint.
44
+ const message = `Error loading ${filePath}, possibly due to a syntax error.\n${error.message}`;
45
+ throw new SyntaxError(message);
43
46
  }
44
47
 
45
48
  if ("default" in obj) {
@@ -21,11 +21,5 @@ export default function InvokeFunctionsTransform(Base) {
21
21
  }
22
22
  return value;
23
23
  }
24
-
25
- // Need to evaluate the value before checking if it is a tree.
26
- async isKeyForSubtree(key) {
27
- const value = await this.get(key);
28
- return Tree.isAsyncTree(value);
29
- }
30
24
  };
31
25
  }
@@ -1,5 +1,5 @@
1
1
  import { ObjectTree, symbols } from "@weborigami/async-tree";
2
- import { attachHandlerIfApplicable, extname } from "./extensions.js";
2
+ import { extname, handleExtension } from "./extensions.js";
3
3
  import { evaluate, ops } from "./internal.js";
4
4
 
5
5
  /**
@@ -36,7 +36,6 @@ export default async function expressionObject(entries, parent) {
36
36
  // has an extension, we need to define a getter. If the value is code (an
37
37
  // array), we need to define a getter -- but if that code takes the form
38
38
  // [ops.getter, <primitive>], we can define a regular property.
39
-
40
39
  let defineProperty;
41
40
  const extension = extname(key);
42
41
  if (extension) {
@@ -50,6 +49,7 @@ export default async function expressionObject(entries, parent) {
50
49
  defineProperty = false;
51
50
  }
52
51
 
52
+ // If the key is wrapped in parentheses, it is not enumerable.
53
53
  let enumerable = true;
54
54
  if (key[0] === "(" && key[key.length - 1] === ")") {
55
55
  key = key.slice(1, -1);
@@ -81,7 +81,7 @@ export default async function expressionObject(entries, parent) {
81
81
  get = async () => {
82
82
  tree ??= new ObjectTree(object);
83
83
  const result = await evaluate.call(tree, code);
84
- return attachHandlerIfApplicable(tree, result, key);
84
+ return handleExtension(tree, result, key);
85
85
  };
86
86
  } else {
87
87
  // No extension, so getter just invokes code.
@@ -5,9 +5,53 @@ import {
5
5
  isUnpackable,
6
6
  scope,
7
7
  symbols,
8
- toString,
8
+ trailingSlash,
9
9
  } from "@weborigami/async-tree";
10
10
 
11
+ /**
12
+ * If the given path ends in an extension, return it. Otherwise, return the
13
+ * empty string.
14
+ *
15
+ * This is meant as a basic replacement for the standard Node `path.extname`.
16
+ * That standard function inaccurately returns an extension for a path that
17
+ * includes a near-final extension but ends in a final slash, like `foo.txt/`.
18
+ * Node thinks that path has a ".txt" extension, but for our purposes it
19
+ * doesn't.
20
+ *
21
+ * @param {string} path
22
+ */
23
+ export function extname(path) {
24
+ // We want at least one character before the dot, then a dot, then a non-empty
25
+ // sequence of characters after the dot that aren't slahes or dots.
26
+ const extnameRegex = /[^/](?<ext>\.[^/\.]+)$/;
27
+ const match = String(path).match(extnameRegex);
28
+ const extension = match?.groups?.ext.toLowerCase() ?? "";
29
+ return extension;
30
+ }
31
+
32
+ /**
33
+ * Find an extension handler for a file in the given container.
34
+ *
35
+ * @typedef {import("@weborigami/types").AsyncTree} AsyncTree
36
+ *
37
+ * @param {AsyncTree} parent
38
+ * @param {string} extension
39
+ */
40
+ export async function getExtensionHandler(parent, extension) {
41
+ const handlerName = `${extension.slice(1)}_handler`;
42
+ const parentScope = scope(parent);
43
+ /** @type {import("../../index.ts").ExtensionHandler} */
44
+ let extensionHandler = await parentScope?.get(handlerName);
45
+ if (isUnpackable(extensionHandler)) {
46
+ // The extension handler itself needs to be unpacked. E.g., if it's a
47
+ // buffer containing JavaScript file, we need to unpack it to get its
48
+ // default export.
49
+ // @ts-ignore
50
+ extensionHandler = await extensionHandler.unpack();
51
+ }
52
+ return extensionHandler;
53
+ }
54
+
11
55
  /**
12
56
  * If the given value is packed (e.g., buffer) and the key is a string-like path
13
57
  * that ends in an extension, search for a handler for that extension and, if
@@ -17,15 +61,23 @@ import {
17
61
  * @param {any} value
18
62
  * @param {any} key
19
63
  */
20
- export async function attachHandlerIfApplicable(parent, value, key) {
64
+ export async function handleExtension(parent, value, key) {
21
65
  if (isPacked(value) && isStringLike(key)) {
22
- key = toString(key);
66
+ const hasSlash = trailingSlash.has(key);
67
+ if (hasSlash) {
68
+ key = trailingSlash.remove(key);
69
+ }
23
70
 
24
71
  // Special case: `.ori.<ext>` extensions are Origami documents.
25
72
  const extension = key.match(/\.ori\.\S+$/) ? ".ori_document" : extname(key);
26
73
  if (extension) {
27
74
  const handler = await getExtensionHandler(parent, extension);
28
75
  if (handler) {
76
+ if (hasSlash && handler.unpack) {
77
+ // Key like `data.json/` ends in slash -- unpack immediately
78
+ return handler.unpack(value, { key, parent });
79
+ }
80
+
29
81
  // If the value is a primitive, box it so we can attach data to it.
30
82
  value = box(value);
31
83
 
@@ -48,47 +100,3 @@ export async function attachHandlerIfApplicable(parent, value, key) {
48
100
  }
49
101
  return value;
50
102
  }
51
-
52
- /**
53
- * Find an extension handler for a file in the given container.
54
- *
55
- * @typedef {import("@weborigami/types").AsyncTree} AsyncTree
56
- *
57
- * @param {AsyncTree} parent
58
- * @param {string} extension
59
- */
60
- export async function getExtensionHandler(parent, extension) {
61
- const handlerName = `${extension.slice(1)}_handler`;
62
- const parentScope = scope(parent);
63
- /** @type {import("../../index.ts").ExtensionHandler} */
64
- let extensionHandler = await parentScope?.get(handlerName);
65
- if (isUnpackable(extensionHandler)) {
66
- // The extension handler itself needs to be unpacked. E.g., if it's a
67
- // buffer containing JavaScript file, we need to unpack it to get its
68
- // default export.
69
- // @ts-ignore
70
- extensionHandler = await extensionHandler.unpack();
71
- }
72
- return extensionHandler;
73
- }
74
-
75
- /**
76
- * If the given path ends in an extension, return it. Otherwise, return the
77
- * empty string.
78
- *
79
- * This is meant as a basic replacement for the standard Node `path.extname`.
80
- * That standard function inaccurately returns an extension for a path that
81
- * includes a near-final extension but ends in a final slash, like `foo.txt/`.
82
- * Node thinks that path has a ".txt" extension, but for our purposes it
83
- * doesn't.
84
- *
85
- * @param {string} path
86
- */
87
- export function extname(path) {
88
- // We want at least one character before the dot, then a dot, then a non-empty
89
- // sequence of characters after the dot that aren't slahes or dots.
90
- const extnameRegex = /[^/](?<ext>\.[^/\.]+)$/;
91
- const match = String(path).match(extnameRegex);
92
- const extension = match?.groups?.ext.toLowerCase() ?? "";
93
- return extension;
94
- }
@@ -23,7 +23,7 @@ export default function formatError(error) {
23
23
  let lines = error.stack.split("\n");
24
24
  for (let i = 0; i < lines.length; i++) {
25
25
  const line = lines[i];
26
- if (origamiSourceSignals.some((signal) => line.includes(signal))) {
26
+ if (maybeOrigamiSourceCode(line)) {
27
27
  break;
28
28
  }
29
29
  if (message) {
@@ -50,3 +50,7 @@ export default function formatError(error) {
50
50
  }
51
51
  return message;
52
52
  }
53
+
54
+ export function maybeOrigamiSourceCode(text) {
55
+ return origamiSourceSignals.some((signal) => text.includes(signal));
56
+ }
@@ -5,14 +5,16 @@
5
5
 
6
6
  import {
7
7
  ObjectTree,
8
+ OpenSiteTree,
8
9
  SiteTree,
9
10
  Tree,
10
11
  isUnpackable,
11
12
  scope as scopeFn,
13
+ trailingSlash,
12
14
  concat as treeConcat,
13
15
  } from "@weborigami/async-tree";
14
16
  import expressionObject from "./expressionObject.js";
15
- import { attachHandlerIfApplicable } from "./extensions.js";
17
+ import { handleExtension } from "./extensions.js";
16
18
  import HandleExtensionsTransform from "./HandleExtensionsTransform.js";
17
19
  import { evaluate } from "./internal.js";
18
20
  import mergeTrees from "./mergeTrees.js";
@@ -47,24 +49,6 @@ export async function concat(...args) {
47
49
  }
48
50
  concat.toString = () => "«ops.concat»";
49
51
 
50
- /**
51
- * Given a protocol, a host, and a list of keys, construct an href.
52
- *
53
- * @param {string} protocol
54
- * @param {string} host
55
- * @param {...string|Symbol} keys
56
- */
57
- function constructHref(protocol, host, ...keys) {
58
- let href = [host, ...keys].join("/");
59
- if (!href.startsWith(protocol)) {
60
- if (!href.startsWith("//")) {
61
- href = `//${href}`;
62
- }
63
- href = `${protocol}${href}`;
64
- }
65
- return href;
66
- }
67
-
68
52
  /**
69
53
  * Find the indicated constructor in scope, then return a function which invokes
70
54
  * it with `new`.
@@ -88,6 +72,49 @@ export async function constructor(...keys) {
88
72
  }
89
73
  constructor.toString = () => "«ops.constructor»";
90
74
 
75
+ /**
76
+ * Given a protocol, a host, and a list of keys, construct an href.
77
+ *
78
+ * @param {string} protocol
79
+ * @param {string} host
80
+ * @param {...string|Symbol} keys
81
+ */
82
+ function constructHref(protocol, host, ...keys) {
83
+ // Remove trailing slashes
84
+ const baseKeys = keys.map((key) => trailingSlash.remove(key));
85
+ let href = [host, ...baseKeys].join("/");
86
+ if (!href.startsWith(protocol)) {
87
+ if (!href.startsWith("//")) {
88
+ href = `//${href}`;
89
+ }
90
+ href = `${protocol}${href}`;
91
+ }
92
+ return href;
93
+ }
94
+
95
+ /**
96
+ * Given a protocol, a host, and a list of keys, construct an href.
97
+ *
98
+ * @param {string} protocol
99
+ * @param {import("../../index.ts").Constructor<AsyncTree>} treeClass
100
+ * @param {AsyncTree|null} parent
101
+ * @param {string} host
102
+ * @param {...string|Symbol} keys
103
+ */
104
+ async function constructSiteTree(protocol, treeClass, parent, host, ...keys) {
105
+ // If the last key doesn't end in a slash, remove it for now.
106
+ let lastKey;
107
+ if (keys.length > 0 && keys.at(-1) && !trailingSlash.has(keys.at(-1))) {
108
+ lastKey = keys.pop();
109
+ }
110
+
111
+ const href = constructHref(protocol, host, ...keys);
112
+ let result = new (HandleExtensionsTransform(treeClass))(href);
113
+ result.parent = parent;
114
+
115
+ return lastKey ? result.get(lastKey) : result;
116
+ }
117
+
91
118
  /**
92
119
  * Fetch the resource at the given href.
93
120
  *
@@ -105,7 +132,7 @@ async function fetchResponse(href) {
105
132
  const url = new URL(href);
106
133
  const filename = url.pathname.split("/").pop();
107
134
  if (this && filename) {
108
- buffer = await attachHandlerIfApplicable(this, buffer, filename);
135
+ buffer = await handleExtension(this, buffer, filename);
109
136
  }
110
137
 
111
138
  return buffer;
@@ -257,6 +284,18 @@ export async function object(...entries) {
257
284
  }
258
285
  object.toString = () => "«ops.object»";
259
286
 
287
+ /**
288
+ * An open tree with JSON Keys via HTTPS.
289
+ *
290
+ * @this {AsyncTree|null}
291
+ * @param {string} host
292
+ * @param {...string|Symbol} keys
293
+ */
294
+ export function openSite(host, ...keys) {
295
+ return constructSiteTree("https:", OpenSiteTree, this, host, ...keys);
296
+ }
297
+ openSite.toString = () => "«ops.openSite»";
298
+
260
299
  /**
261
300
  * Look up the given key in the scope for the current tree.
262
301
  *
@@ -303,10 +342,7 @@ export const traverse = Tree.traverseOrThrow;
303
342
  * @param {...string|Symbol} keys
304
343
  */
305
344
  export function treeHttp(host, ...keys) {
306
- const href = constructHref("http:", host, ...keys);
307
- let result = new (HandleExtensionsTransform(SiteTree))(href);
308
- result.parent = this;
309
- return result;
345
+ return constructSiteTree("http:", SiteTree, this, host, ...keys);
310
346
  }
311
347
  treeHttp.toString = () => "«ops.treeHttp»";
312
348
 
@@ -318,9 +354,16 @@ treeHttp.toString = () => "«ops.treeHttp»";
318
354
  * @param {...string|Symbol} keys
319
355
  */
320
356
  export function treeHttps(host, ...keys) {
321
- const href = constructHref("https:", host, ...keys);
322
- let result = new (HandleExtensionsTransform(SiteTree))(href);
323
- result.parent = this;
324
- return result;
357
+ return constructSiteTree("https:", SiteTree, this, host, ...keys);
325
358
  }
326
359
  treeHttps.toString = () => "«ops.treeHttps»";
360
+
361
+ /**
362
+ * If the value is packed but has an unpack method, call it and return that as
363
+ * the result; otherwise, return the value as is.
364
+ *
365
+ * @param {any} value
366
+ */
367
+ export async function unpack(value) {
368
+ return isUnpackable(value) ? value.unpack() : value;
369
+ }
@@ -36,11 +36,17 @@ describe("compile", () => {
36
36
  await assertCompile("-1", -1);
37
37
  });
38
38
 
39
- test("object", async () => {
39
+ test("sync object", async () => {
40
40
  await assertCompile("{a:1, b:2}", { a: 1, b: 2 });
41
41
  await assertCompile("{ a: { b: { c: 0 } } }", { a: { b: { c: 0 } } });
42
42
  });
43
43
 
44
+ test("async object", async () => {
45
+ const fn = compile.expression("{ a: { b = name }}");
46
+ const object = await fn.call(shared);
47
+ assert.deepEqual(await object["a/"].b, "Alice");
48
+ });
49
+
44
50
  test("templateDocument", async () => {
45
51
  const fn = compile.templateDocument("Documents can contain ` backticks");
46
52
  const templateFn = await fn.call(shared);