@weborigami/language 0.6.0 → 0.6.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,11 +1,7 @@
1
- import {
2
- extension,
3
- getParent,
4
- toString,
5
- trailingSlash,
6
- } from "@weborigami/async-tree";
1
+ import { extension, getParent, trailingSlash } from "@weborigami/async-tree";
7
2
  import * as compile from "../compiler/compile.js";
8
3
  import projectGlobals from "../project/projectGlobals.js";
4
+ import getSource from "./getSource.js";
9
5
 
10
6
  /**
11
7
  * An Origami template document: a plain text file that contains Origami
@@ -17,30 +13,10 @@ export default {
17
13
  /** @type {import("@weborigami/async-tree").UnpackFunction} */
18
14
  async unpack(packed, options = {}) {
19
15
  const parent = getParent(packed, options);
16
+ const source = getSource(packed, options);
20
17
 
21
- // Unpack as a text document
22
- const text = toString(packed);
23
-
24
- // See if we can construct a URL to use in error messages
25
- const key = options.key;
26
- let url;
27
- if (key && /** @type {any} */ (parent)?.url) {
28
- let parentHref = /** @type {any} */ (parent).url.href;
29
- if (!parentHref.endsWith("/")) {
30
- parentHref += "/";
31
- }
32
- url = new URL(key, parentHref);
33
- }
34
-
35
- // Compile the text as an Origami template document
36
- const source = {
37
- name: key,
38
- text,
39
- url,
40
- };
41
-
18
+ // Compile the source code as an Origami template document
42
19
  const globals = options.globals ?? (await projectGlobals());
43
-
44
20
  const defineFn = compile.templateDocument(source, {
45
21
  front: options.front,
46
22
  globals,
@@ -51,6 +27,7 @@ export default {
51
27
  // Invoke the definition to get back the template function
52
28
  const result = await defineFn();
53
29
 
30
+ const key = options.key;
54
31
  const resultExtension = key ? extension.extname(key) : null;
55
32
  if (resultExtension && Object.isExtensible(result)) {
56
33
  // Add sidecar function so this template can be used in a map.
@@ -1,11 +1,31 @@
1
- import { symbols, toString } from "@weborigami/async-tree";
1
+ import {
2
+ getParent,
3
+ isUnpackable,
4
+ symbols,
5
+ toString,
6
+ Tree,
7
+ } from "@weborigami/async-tree";
2
8
  import * as YAMLModule from "yaml";
9
+ import * as compile from "../compiler/compile.js";
10
+ import projectGlobals from "../project/projectGlobals.js";
11
+ import getSource from "./getSource.js";
3
12
 
4
13
  // The "yaml" package doesn't seem to provide a default export that the browser can
5
14
  // recognize, so we have to handle two ways to accommodate Node and the browser.
6
15
  // @ts-ignore
7
16
  const YAML = YAMLModule.default ?? YAMLModule.YAML;
8
17
 
18
+ // True if we encountered !ori or !ori.call tags while parsing
19
+ let hasOriTags = false;
20
+
21
+ // When processing the !ori tag, the YAML parser will convert our compiler
22
+ // errors into YAML syntax errors. We track the last compiler error so we can
23
+ // re-throw it.
24
+ let lastCompilerError = null;
25
+
26
+ // The source of the last Origami line parsed in a YAML file, used for errors
27
+ let source;
28
+
9
29
  /**
10
30
  * A YAML file
11
31
  *
@@ -16,18 +36,154 @@ export default {
16
36
  mediaType: "application/yaml",
17
37
 
18
38
  /** @type {import("@weborigami/async-tree").UnpackFunction} */
19
- unpack(packed) {
39
+ async unpack(packed, options = {}) {
20
40
  const yaml = toString(packed);
21
41
  if (!yaml) {
22
42
  throw new Error("Tried to parse something as YAML but it wasn't text.");
23
43
  }
24
- const data = YAML.parse(yaml);
44
+ const parent = getParent(packed, options);
45
+ const oriCallTag = await oriCallTagForParent(parent, options, yaml);
46
+ const oriTag = await oriTagForParent(parent, options, yaml);
47
+ hasOriTags = false; // Haven't seen any yet
48
+
49
+ let data;
50
+ // YAML parser is sync, but top-level !ori or !ori.call tags will return a
51
+ // promise.
52
+ try {
53
+ // @ts-ignore TypeScript complains customTags isn't valid here but it is.
54
+ data = YAML.parse(yaml, {
55
+ customTags: [oriCallTag, oriTag],
56
+ });
57
+ } catch (/** @type {any} */ error) {
58
+ if (error.name === "SyntaxError") {
59
+ // One of our compiler errors, probably thrown by !ori.call tag
60
+ throw error;
61
+ }
62
+ const errorText = yaml.slice(error.pos[0], error.pos[1]);
63
+ const isOriError = errorText === "!ori" || errorText === "!ori.call";
64
+ if (isOriError && lastCompilerError) {
65
+ // Error is in an Origami tag, probably throw by !ori tag. Find the
66
+ // position of the Origami source in the YAML text.
67
+ let offset = error.pos[0] + errorText.length;
68
+ while (/\s/.test(yaml[offset])) {
69
+ offset++;
70
+ }
71
+ lastCompilerError.location.source.offset = offset;
72
+ throw lastCompilerError;
73
+ } else {
74
+ // Some other YAML parsing error
75
+ throw error;
76
+ }
77
+ }
78
+
79
+ if (data instanceof Promise) {
80
+ // Top-level !ori or !ori.call tag returned a promise
81
+ data = await data;
82
+ }
83
+
84
+ if (hasOriTags) {
85
+ // Resolve any promises in the data.
86
+ data = await Tree.plain(data);
87
+ }
88
+
25
89
  if (data && typeof data === "object" && Object.isExtensible(data)) {
26
90
  Object.defineProperty(data, symbols.deep, {
27
91
  enumerable: false,
28
92
  value: true,
29
93
  });
30
94
  }
95
+
31
96
  return data;
32
97
  },
33
98
  };
99
+
100
+ async function oriCallTagForParent(parent, options, yaml) {
101
+ const globals = await projectGlobals();
102
+ return {
103
+ collection: "seq",
104
+
105
+ tag: "!ori.call",
106
+
107
+ resolve(value) {
108
+ hasOriTags = true;
109
+ /** @type {any[]} */
110
+ const args = typeof value?.toJSON === "function" ? value.toJSON() : value;
111
+
112
+ // First arg is Origami source
113
+ const text = args.shift();
114
+ source = getSource(text, options);
115
+
116
+ // Offset the source position to account for its location in YAML text
117
+ source.context = yaml;
118
+ const firstItem = value.items[0];
119
+ source.offset = firstItem.range[0];
120
+ if (
121
+ firstItem.type === "QUOTE_DOUBLE" ||
122
+ firstItem.type === "QUOTE_SINGLE"
123
+ ) {
124
+ // Account for opening quote
125
+ source.offset += 1;
126
+ }
127
+
128
+ lastCompilerError = null;
129
+ let codeFn;
130
+ try {
131
+ codeFn = compile.expression(source, {
132
+ globals,
133
+ parent,
134
+ });
135
+ } catch (error) {
136
+ lastCompilerError = error;
137
+ throw error;
138
+ }
139
+
140
+ // Return a promise for the code's evaluation. If we instead define
141
+ // resolve() as async, the catch block in unpack() won't catch Origami
142
+ // parse errors.
143
+ return new Promise(async (resolve) => {
144
+ // Evaluate the code to get a function
145
+ let fn = await codeFn.call(parent);
146
+
147
+ // Call the function with the rest of the args
148
+ if (isUnpackable(fn)) {
149
+ fn = await fn.unpack();
150
+ }
151
+
152
+ // Resolve any promise args
153
+ const resolvedArgs = await Promise.all(args);
154
+
155
+ const result = await fn.call(null, ...resolvedArgs);
156
+ resolve(result);
157
+ });
158
+ },
159
+ };
160
+ }
161
+
162
+ // Define the !ori tag for YAML parsing. This will run in the context of the
163
+ // supplied parent.
164
+ async function oriTagForParent(parent, options, yaml) {
165
+ const globals = await projectGlobals();
166
+ return {
167
+ resolve(text) {
168
+ hasOriTags = true;
169
+ source = getSource(text, options);
170
+ source.context = yaml;
171
+
172
+ lastCompilerError = null;
173
+ let fn;
174
+ try {
175
+ fn = compile.expression(source, {
176
+ globals,
177
+ parent,
178
+ });
179
+ } catch (error) {
180
+ lastCompilerError = error;
181
+ throw error;
182
+ }
183
+
184
+ return fn.call(parent);
185
+ },
186
+
187
+ tag: "!ori",
188
+ };
189
+ }
@@ -12,7 +12,7 @@ import path from "node:path";
12
12
  * Fetch API
13
13
  * URL API
14
14
  */
15
- const globals = bindStaticMethodsForGlobals({
15
+ const globals = {
16
16
  AbortController,
17
17
  AbortSignal,
18
18
  AggregateError,
@@ -152,7 +152,7 @@ const globals = bindStaticMethodsForGlobals({
152
152
  // Special cases
153
153
  fetch: fetchWrapper,
154
154
  import: importWrapper,
155
- });
155
+ };
156
156
 
157
157
  // Give access to our own custom globals as `globalThis`
158
158
  Object.defineProperty(globals, "globalThis", {
@@ -190,64 +190,4 @@ async function importWrapper(modulePath) {
190
190
  }
191
191
  importWrapper.containerAsTarget = true;
192
192
 
193
- /**
194
- * Some JavaScript globals like Promise have static methods like Promise.all
195
- * verify that the call target is the class. This creates an issue because the
196
- * Origami evaluate() function calls all functions with the evaluation context
197
- * -- the tree in which the code is running -- as the call target.
198
- *
199
- * This function works around the problem. If the indicated object has no static
200
- * methods, it's returned as is. If it does have static methods, this returns an
201
- * extension of the object that overrides the static methods with ones that are
202
- * bound to the object.
203
- */
204
- function bindStaticMethods(obj) {
205
- if (typeof obj !== "function" && (typeof obj !== "object" || obj === null)) {
206
- // Something like `NaN` or `null`
207
- return obj;
208
- }
209
-
210
- const staticMethodDescriptors = Object.entries(
211
- Object.getOwnPropertyDescriptors(obj)
212
- )
213
- .filter(([key, descriptor]) => descriptor.value instanceof Function)
214
- .map(([key, descriptor]) => [
215
- key,
216
- {
217
- ...descriptor,
218
- value: descriptor.value.bind(obj),
219
- },
220
- ]);
221
- if (staticMethodDescriptors.length === 0) {
222
- // No static methods
223
- return obj;
224
- }
225
-
226
- let extended;
227
- if (typeof obj === "object" || !obj.prototype) {
228
- // A regular object or an oddball like Proxy with no prototype
229
- extended = Object.create(obj);
230
- } else {
231
- // A function, possibly a constructor called with or without `new`
232
- /** @this {any} */
233
- extended = function (...args) {
234
- const calledWithNew = this instanceof extended;
235
- return calledWithNew ? new obj(...args) : obj(...args);
236
- };
237
- }
238
-
239
- Object.defineProperties(
240
- extended,
241
- Object.fromEntries(staticMethodDescriptors)
242
- );
243
-
244
- return extended;
245
- }
246
-
247
- function bindStaticMethodsForGlobals(objects) {
248
- const entries = Object.entries(objects);
249
- const bound = entries.map(([key, value]) => [key, bindStaticMethods(value)]);
250
- return Object.fromEntries(bound);
251
- }
252
-
253
193
  export default globals;
@@ -9,6 +9,9 @@ import { pathFromKeys } from "@weborigami/async-tree";
9
9
  */
10
10
  export default function constructHref(protocol, host, ...keys) {
11
11
  const path = pathFromKeys(keys);
12
+ if (host.endsWith("/")) {
13
+ host = host.slice(0, -1);
14
+ }
12
15
  let href = [host, path].join("/");
13
16
  if (!href.startsWith(protocol)) {
14
17
  if (!href.startsWith("//")) {
@@ -0,0 +1,13 @@
1
+ import { ExplorableSiteMap } from "@weborigami/async-tree";
2
+ import constructSiteTree from "./constructSiteTree.js";
3
+
4
+ /**
5
+ * A site tree with JSON Keys via HTTP.
6
+ *
7
+ *
8
+ * @param {string} host
9
+ * @param {...string} keys
10
+ */
11
+ export default function explorehttp(host, ...keys) {
12
+ return constructSiteTree("http:", ExplorableSiteMap, host, ...keys);
13
+ }
@@ -2,6 +2,7 @@ import * as protocols from "./protocols.js";
2
2
 
3
3
  export default {
4
4
  "explore:": protocols.explore,
5
+ "explorehttp:": protocols.explorehttp,
5
6
  "files:": protocols.files,
6
7
  "http:": protocols.http,
7
8
  "https:": protocols.https,
@@ -1,4 +1,5 @@
1
1
  export { default as explore } from "./explore.js";
2
+ export { default as explorehttp } from "./explorehttp.js";
2
3
  export { default as files } from "./files.js";
3
4
  export { default as http } from "./http.js";
4
5
  export { default as https } from "./https.js";
@@ -95,7 +95,9 @@ export function formatError(error) {
95
95
  error.message === "A null or undefined value can't be traversed"
96
96
  ) {
97
97
  // Provide more meaningful message for TraverseError
98
- line = `TraverseError: This part of the path is null or undefined: ${fragment}`;
98
+ line = `TraverseError: This part of the path is null or undefined: ${highlightError(
99
+ fragment
100
+ )}`;
99
101
  fragmentInMessage = true;
100
102
  }
101
103
  if (message) {
@@ -110,7 +112,7 @@ export function formatError(error) {
110
112
  // Add location
111
113
  if (location) {
112
114
  if (!fragmentInMessage) {
113
- message += `\nevaluating: ${fragment}`;
115
+ message += `\nevaluating: ${highlightError(fragment)}`;
114
116
  }
115
117
  message += lineInfo(location);
116
118
  }
@@ -130,6 +132,11 @@ export async function formatScopeTypos(scope, key) {
130
132
  return `Maybe you meant ${list}?`;
131
133
  }
132
134
 
135
+ export function highlightError(text) {
136
+ // ANSI escape sequence to highlight text in red
137
+ return `\x1b[31m${text}\x1b[0m`;
138
+ }
139
+
133
140
  export function maybeOrigamiSourceCode(text) {
134
141
  return origamiSourceSignals.some((signal) => text.includes(signal));
135
142
  }
@@ -137,9 +144,22 @@ export function maybeOrigamiSourceCode(text) {
137
144
  // Return user-friendly line information for the error location
138
145
  function lineInfo(location) {
139
146
  let { source, start } = location;
140
- // Adjust line number with offset if present (for example, if the code is in
141
- // an Origami template document with front matter that was stripped)
142
- let line = start.line + (source.offset ?? 0);
147
+
148
+ let line;
149
+ let column;
150
+ if (source.offset && source.context) {
151
+ // Account for source code that was offset within a larger document
152
+ const offset = source.offset + start.offset;
153
+ // Calculate line and column from offset
154
+ const textUpToOffset = source.context.slice(0, offset);
155
+ const lines = textUpToOffset.split("\n");
156
+ line = lines.length;
157
+ column = lines[lines.length - 1].length + 1;
158
+ } else {
159
+ // Use indicated start location as is
160
+ line = start.line;
161
+ column = start.column;
162
+ }
143
163
 
144
164
  if (typeof source === "object" && source.url) {
145
165
  const { url } = source;
@@ -155,10 +175,10 @@ function lineInfo(location) {
155
175
  // Not a file: URL, use as is
156
176
  fileRef = url.href;
157
177
  }
158
- return `\n at ${fileRef}:${line}:${start.column}`;
178
+ return `\n at ${fileRef}:${line}:${column}`;
159
179
  } else if (source.text.includes("\n")) {
160
180
  // Don't know the URL, but has multiple lines so add line number
161
- return `\n at line ${line}, column ${start.column}`;
181
+ return `\n at line ${line}, column ${column}`;
162
182
  } else {
163
183
  return "";
164
184
  }
@@ -36,7 +36,7 @@ export default async function evaluate(code, state = {}) {
36
36
  const error = ReferenceError(
37
37
  `${codeFragment(code[0].location)} is not defined`
38
38
  );
39
- /** @type {any} */ (error).location = code.location;
39
+ /** @type {any} */ (error).location = code[0].location;
40
40
  throw error;
41
41
  }
42
42
 
@@ -41,11 +41,6 @@ export default async function handleExtension(value, key, parent) {
41
41
  handler = await handler.unpack();
42
42
  }
43
43
 
44
- if (hasSlash && handler.unpack) {
45
- // Key like `data.json/` ends in slash -- unpack immediately
46
- return handler.unpack(value, { key, parent });
47
- }
48
-
49
44
  // If the value is a primitive, box it so we can attach data to it.
50
45
  value = box(value);
51
46
 
@@ -181,6 +181,11 @@ export async function homeDirectory(...keys) {
181
181
  }
182
182
  addOpLabel(homeDirectory, "«ops.homeDirectory»");
183
183
 
184
+ export function inOperator(a, b) {
185
+ return a in b;
186
+ }
187
+ addOpLabel(inOperator, "«ops.inOperator»");
188
+
184
189
  /**
185
190
  * Given the tree currently be using as the context for the runtime, walk up the
186
191
  * parent chain `depth` levels and return that tree.
@@ -203,6 +208,11 @@ export async function inherited(depth, state) {
203
208
  addOpLabel(inherited, "«ops.inherited»");
204
209
  inherited.needsState = true;
205
210
 
211
+ export function instanceOf(a, b) {
212
+ return a instanceof b;
213
+ }
214
+ addOpLabel(instanceOf, "«ops.instanceOf»");
215
+
206
216
  /**
207
217
  * Return a function that will invoke the given code.
208
218
  *
@@ -233,8 +233,8 @@ describe("Origami parser", () => {
233
233
 
234
234
  test("with paths", () => {
235
235
  assertParse("callExpression", "tree/", [
236
- ops.unpack,
237
- [markers.traverse, [markers.reference, "tree/"]],
236
+ markers.traverse,
237
+ [markers.reference, "tree/"],
238
238
  ]);
239
239
  assertParse("callExpression", "tree/foo/bar", [
240
240
  markers.traverse,
@@ -243,13 +243,10 @@ describe("Origami parser", () => {
243
243
  [ops.literal, "bar"],
244
244
  ]);
245
245
  assertParse("callExpression", "tree/foo/bar/", [
246
- ops.unpack,
247
- [
248
- markers.traverse,
249
- [markers.reference, "tree/"],
250
- [ops.literal, "foo/"],
251
- [ops.literal, "bar/"],
252
- ],
246
+ markers.traverse,
247
+ [markers.reference, "tree/"],
248
+ [ops.literal, "foo/"],
249
+ [ops.literal, "bar/"],
253
250
  ]);
254
251
  // Consecutive slahes in a path are removed
255
252
  assertParse("callExpression", "tree//key", [
@@ -1032,7 +1029,7 @@ Body`,
1032
1029
  ]);
1033
1030
  assertParse("objectEntry", "folder/", [
1034
1031
  "folder/",
1035
- [ops.unpack, [markers.traverse, [markers.reference, "folder/"]]],
1032
+ [markers.traverse, [markers.reference, "folder/"]],
1036
1033
  ]);
1037
1034
  assertParse("objectEntry", "path/to/file.txt", [
1038
1035
  "file.txt",
@@ -1202,8 +1199,8 @@ Body`,
1202
1199
  [markers.reference, "tree"],
1203
1200
  ]);
1204
1201
  assertParse("pathLiteral", "tree/", [
1205
- ops.unpack,
1206
- [markers.traverse, [markers.reference, "tree/"]],
1202
+ markers.traverse,
1203
+ [markers.reference, "tree/"],
1207
1204
  ]);
1208
1205
  assertParse("pathLiteral", "month/12", [
1209
1206
  markers.traverse,
@@ -1211,13 +1208,10 @@ Body`,
1211
1208
  [ops.literal, "12"],
1212
1209
  ]);
1213
1210
  assertParse("pathLiteral", "a/b/c/", [
1214
- ops.unpack,
1215
- [
1216
- markers.traverse,
1217
- [markers.reference, "a/"],
1218
- [ops.literal, "b/"],
1219
- [ops.literal, "c/"],
1220
- ],
1211
+ markers.traverse,
1212
+ [markers.reference, "a/"],
1213
+ [ops.literal, "b/"],
1214
+ [ops.literal, "c/"],
1221
1215
  ]);
1222
1216
  assertParse("pathLiteral", "~/.cshrc", [
1223
1217
  markers.traverse,
@@ -8,10 +8,37 @@ describe(".yaml handler", () => {
8
8
  a: 1
9
9
  b: 2
10
10
  `;
11
- const data = yaml_handler.unpack(text);
11
+ const data = await yaml_handler.unpack(text);
12
12
  assert.deepEqual(data, {
13
13
  a: 1,
14
14
  b: 2,
15
15
  });
16
16
  });
17
+
18
+ test("defines !ori tag for Origami expressions", async () => {
19
+ const text = `
20
+ message: Hello
21
+ answer: !ori 1 + 1
22
+ `;
23
+ const data = await yaml_handler.unpack(text);
24
+ assert.deepEqual(data, {
25
+ message: "Hello",
26
+ answer: 2,
27
+ });
28
+ });
29
+
30
+ test("defines !ori.call tag for Origami function invocation", async () => {
31
+ const text = `
32
+ message: Hello
33
+ answer: !ori.call
34
+ - (a, b) => a + b
35
+ - 2
36
+ - 3
37
+ `;
38
+ const data = await yaml_handler.unpack(text);
39
+ assert.deepEqual(data, {
40
+ message: "Hello",
41
+ answer: 5,
42
+ });
43
+ });
17
44
  });
@@ -3,19 +3,25 @@ import { describe, test } from "node:test";
3
3
  import jsGlobals from "../../src/project/jsGlobals.js";
4
4
 
5
5
  describe("jsGlobals", () => {
6
- test("can invoke static methods", async () => {
7
- const { Promise } = jsGlobals;
8
- const { all } = Promise;
9
- const result = (
10
- await all(["fruit", "computer", "park"].map((item) => `Apple ${item}`))
11
- ).join(", ");
12
- assert.equal(result, "Apple fruit, Apple computer, Apple park");
6
+ test("can invoke finicky methods like Promise.all that check their receiver", async () => {
7
+ const value = await jsGlobals.Promise.all([Promise.resolve("hi")]);
8
+ assert.equal(value, "hi");
13
9
  });
14
10
 
15
- test("can invoke a method on a static method", () => {
11
+ test("can invoke a static method on a global", () => {
16
12
  const { Math } = jsGlobals;
17
13
  const a = [1, 3, 2];
18
14
  const b = Math.max.apply(null, a);
19
15
  assert.equal(b, 3);
20
16
  });
17
+
18
+ test("can invoke a global constructor", async () => {
19
+ const { Number: fixture } = jsGlobals;
20
+ // Without `new`
21
+ const instance1 = fixture(5);
22
+ assert.equal(instance1, 5);
23
+ // With `new`
24
+ const instance = new fixture();
25
+ assert(instance instanceof Number);
26
+ });
21
27
  });
@@ -15,13 +15,6 @@ describe("handleExtension", () => {
15
15
  const data = await withHandler.unpack();
16
16
  assert.deepEqual(data, { bar: 2 });
17
17
  });
18
-
19
- test("immediately unpacks if key ends in slash", async () => {
20
- const fixture = createFixture();
21
- const jsonFile = await fixture.get("bar.json");
22
- const data = await handleExtension(jsonFile, "bar.json/", fixture);
23
- assert.deepEqual(data, { bar: 2 });
24
- });
25
18
  });
26
19
 
27
20
  function createFixture() {