@weborigami/language 0.2.10 → 0.2.11

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,7 +1,13 @@
1
1
  import { trailingSlash } from "@weborigami/async-tree";
2
+ import * as YAMLModule from "yaml";
2
3
  import codeFragment from "../runtime/codeFragment.js";
3
4
  import * as ops from "../runtime/ops.js";
4
5
 
6
+ // The "yaml" package doesn't seem to provide a default export that the browser can
7
+ // recognize, so we have to handle two ways to accommodate Node and the browser.
8
+ // @ts-ignore
9
+ const YAML = YAMLModule.default ?? YAMLModule.YAML;
10
+
5
11
  // Parser helpers
6
12
 
7
13
  /** @typedef {import("../../index.ts").AnnotatedCode} AnnotatedCode */
@@ -30,6 +36,28 @@ export function annotate(code, location) {
30
36
  return annotated;
31
37
  }
32
38
 
39
+ /**
40
+ * In the given code, replace all scope refernces to the given name with the
41
+ * given macro code.
42
+ *
43
+ * @param {AnnotatedCode} code
44
+ * @param {string} name
45
+ * @param {AnnotatedCode} macro
46
+ */
47
+ export function applyMacro(code, name, macro) {
48
+ if (!(code instanceof Array)) {
49
+ return code;
50
+ }
51
+
52
+ const [fn, ...args] = code;
53
+ if (fn === ops.scope && args[0] === name) {
54
+ return macro;
55
+ }
56
+
57
+ const applied = code.map((child) => applyMacro(child, name, macro));
58
+ return annotate(applied, code.location);
59
+ }
60
+
33
61
  /**
34
62
  * The indicated code is being used to define a property named by the given key.
35
63
  * Rewrite any [ops.scope, key] calls to be [ops.inherited, key] to avoid
@@ -385,6 +413,59 @@ export function makeUnaryOperation(operator, value, location) {
385
413
  return annotate([operators[operator], value], location);
386
414
  }
387
415
 
416
+ /**
417
+ * Make an object from YAML front matter
418
+ *
419
+ * @param {string} text
420
+ * @param {CodeLocation} location
421
+ */
422
+ export function makeYamlObject(text, location) {
423
+ // Account for the "---" delimiter at the beginning of the YAML front matter
424
+ const yamlLineDelta = 1;
425
+ const yamlOffsetDelta = 4; // 3 dashes + 1 newline
426
+
427
+ let parsed;
428
+ try {
429
+ parsed = YAML.parse(text);
430
+ } catch (/** @type {any} */ yamlError) {
431
+ // Convert YAML error to a SyntaxError
432
+
433
+ let { message } = yamlError;
434
+ // Remove the line number and column if present
435
+ const lineNumberRegex = /( at line )(\d+)(,)/;
436
+ const lineNumberMatch = message.match(lineNumberRegex);
437
+ if (lineNumberMatch) {
438
+ message = message.slice(0, lineNumberMatch.index);
439
+ }
440
+
441
+ /** @type {any} */
442
+ const error = new SyntaxError(message);
443
+ error.location = {
444
+ end: {
445
+ column: yamlError.linePos[1].col,
446
+ line: yamlError.linePos[1].line + yamlLineDelta,
447
+ offset: yamlError.pos[1] + yamlOffsetDelta,
448
+ },
449
+ source: location.source,
450
+ start: {
451
+ column: yamlError.linePos[0].col,
452
+ line: yamlError.linePos[0].line + yamlLineDelta,
453
+ offset: yamlError.pos[0] + yamlOffsetDelta,
454
+ },
455
+ };
456
+ throw error;
457
+ }
458
+
459
+ if (!(parsed instanceof Object)) {
460
+ /** @type {any} */
461
+ const error = new SyntaxError("YAML front matter must be an object.");
462
+ error.location = location;
463
+ throw error;
464
+ }
465
+
466
+ return annotate([ops.literal, parsed], location);
467
+ }
468
+
388
469
  /**
389
470
  * Upgrade a potential builtin reference to an actual builtin reference.
390
471
  *
@@ -5,6 +5,8 @@ import {
5
5
  trailingSlash,
6
6
  TraverseError,
7
7
  } from "@weborigami/async-tree";
8
+ import path from "node:path";
9
+ import { fileURLToPath } from "node:url";
8
10
  import codeFragment from "./codeFragment.js";
9
11
  import { typos } from "./typos.js";
10
12
 
@@ -85,9 +87,24 @@ export function formatError(error) {
85
87
  if (!fragmentInMessage) {
86
88
  message += `\nevaluating: ${fragment}`;
87
89
  }
90
+
88
91
  if (typeof source === "object" && source.url) {
89
- message += `\n at ${source.url.href}:${line}:${start.column}`;
92
+ const { url } = source;
93
+ let fileRef;
94
+ // If URL is a file: URL, change to a relative path
95
+ if (url.protocol === "file:") {
96
+ fileRef = fileURLToPath(url);
97
+ const relativePath = path.relative(process.cwd(), fileRef);
98
+ if (!relativePath.startsWith("..")) {
99
+ fileRef = relativePath;
100
+ }
101
+ } else {
102
+ // Not a file: URL, use as is
103
+ fileRef = url.href;
104
+ }
105
+ message += `\n at ${fileRef}:${line}:${start.column}`;
90
106
  } else if (source.text.includes("\n")) {
107
+ // Don't know the URL, but has multiple lines so add line number
91
108
  message += `\n at line ${line}, column ${start.column}`;
92
109
  }
93
110
  }
@@ -113,7 +130,7 @@ export function maybeOrigamiSourceCode(text) {
113
130
 
114
131
  export async function scopeReferenceError(scope, key) {
115
132
  const messages = [
116
- `"${key}" is not in scope.`,
133
+ `"${key}" is not in scope or is undefined.`,
117
134
  await formatScopeTypos(scope, key),
118
135
  ];
119
136
  const message = messages.join(" ");
@@ -1,6 +1,5 @@
1
1
  import { Tree, isUnpackable, scope } from "@weborigami/async-tree";
2
2
  import codeFragment from "./codeFragment.js";
3
- import { ops } from "./internal.js";
4
3
  import { codeSymbol, scopeSymbol, sourceSymbol } from "./symbols.js";
5
4
 
6
5
  /**
@@ -20,14 +19,7 @@ export default async function evaluate(code) {
20
19
  }
21
20
 
22
21
  let evaluated;
23
- const unevaluatedFns = [
24
- ops.external,
25
- ops.lambda,
26
- ops.merge,
27
- ops.object,
28
- ops.literal,
29
- ];
30
- if (unevaluatedFns.includes(code[0])) {
22
+ if (code[0]?.unevaluatedArgs) {
31
23
  // Don't evaluate instructions, use as is.
32
24
  evaluated = code;
33
25
  } else {
@@ -10,6 +10,7 @@ import {
10
10
  Tree,
11
11
  isUnpackable,
12
12
  scope as scopeFn,
13
+ symbols,
13
14
  concat as treeConcat,
14
15
  } from "@weborigami/async-tree";
15
16
  import os from "node:os";
@@ -113,6 +114,29 @@ export async function conditional(condition, truthy, falsy) {
113
114
  return value instanceof Function ? await value() : value;
114
115
  }
115
116
 
117
+ /**
118
+ * Construct a document object by invoking the body code (a lambda) and adding
119
+ * the resulting text to the front data.
120
+ *
121
+ * @this {AsyncTree|null}
122
+ * @param {any} frontData
123
+ * @param {AnnotatedCode} bodyCode
124
+ */
125
+ export async function document(frontData, bodyCode) {
126
+ const context = new ObjectTree(frontData);
127
+ context.parent = this;
128
+ const bodyFn = await evaluate.call(context, bodyCode);
129
+ const body = await bodyFn();
130
+ const object = {
131
+ ...frontData,
132
+ "@text": body,
133
+ };
134
+ object[symbols.parent] = this;
135
+ return object;
136
+ }
137
+ addOpLabel(document, "«ops.document");
138
+ document.unevaluatedArgs = true;
139
+
116
140
  export function division(a, b) {
117
141
  return a / b;
118
142
  }
@@ -158,6 +182,7 @@ export async function external(path, code, cache) {
158
182
  return value;
159
183
  }
160
184
  addOpLabel(external, "«ops.external»");
185
+ external.unevaluatedArgs = true;
161
186
 
162
187
  /**
163
188
  * This op is only used during parsing. It signals to ops.object that the
@@ -263,6 +288,7 @@ export function lambda(parameters, code) {
263
288
  return invoke;
264
289
  }
265
290
  addOpLabel(lambda, "«ops.lambda");
291
+ lambda.unevaluatedArgs = true;
266
292
 
267
293
  export function lessThan(a, b) {
268
294
  return a < b;
@@ -284,6 +310,7 @@ export async function literal(value) {
284
310
  return value;
285
311
  }
286
312
  addOpLabel(literal, "«ops.literal»");
313
+ literal.unevaluatedArgs = true;
287
314
 
288
315
  /**
289
316
  * Logical AND operator
@@ -377,6 +404,7 @@ export async function merge(...codes) {
377
404
  return mergeTrees.call(this, ...trees);
378
405
  }
379
406
  addOpLabel(merge, "«ops.merge»");
407
+ merge.unevaluatedArgs = true;
380
408
 
381
409
  export function multiplication(a, b) {
382
410
  return a * b;
@@ -425,6 +453,7 @@ export async function object(...entries) {
425
453
  return expressionObject(entries, this);
426
454
  }
427
455
  addOpLabel(object, "«ops.object»");
456
+ object.unevaluatedArgs = true;
428
457
 
429
458
  export function remainder(a, b) {
430
459
  return a % b;
@@ -4,7 +4,7 @@ import assert from "node:assert";
4
4
  export function assertCodeEqual(actual, expected) {
5
5
  const actualStripped = stripCodeLocations(actual);
6
6
  const expectedStripped = stripCodeLocations(expected);
7
- assert.deepEqual(actualStripped, expectedStripped);
7
+ assert.deepStrictEqual(actualStripped, expectedStripped);
8
8
  }
9
9
 
10
10
  /**
@@ -251,20 +251,20 @@ describe("Origami parser", () => {
251
251
  assertParse("conditionalExpression", "true ? 1 : 0", [
252
252
  ops.conditional,
253
253
  [ops.scope, "true"],
254
- [ops.literal, "1"],
255
- [ops.literal, "0"],
254
+ [ops.literal, 1],
255
+ [ops.literal, 0],
256
256
  ]);
257
257
  assertParse("conditionalExpression", "false ? () => 1 : 0", [
258
258
  ops.conditional,
259
259
  [ops.scope, "false"],
260
- [ops.lambda, [], [ops.lambda, [], [ops.literal, "1"]]],
261
- [ops.literal, "0"],
260
+ [ops.lambda, [], [ops.lambda, [], [ops.literal, 1]]],
261
+ [ops.literal, 0],
262
262
  ]);
263
263
  assertParse("conditionalExpression", "false ? =1 : 0", [
264
264
  ops.conditional,
265
265
  [ops.scope, "false"],
266
- [ops.lambda, [], [ops.lambda, [[ops.literal, "_"]], [ops.literal, "1"]]],
267
- [ops.literal, "0"],
266
+ [ops.lambda, [], [ops.lambda, [[ops.literal, "_"]], [ops.literal, 1]]],
267
+ [ops.literal, 0],
268
268
  ]);
269
269
  });
270
270
 
@@ -286,7 +286,7 @@ describe("Origami parser", () => {
286
286
  ]);
287
287
  });
288
288
 
289
- test("errors for missing pieces", () => {
289
+ test("error thrown for missing token", () => {
290
290
  assertThrows("arrowFunction", "(a) => ", "Expected an expression");
291
291
  assertThrows("arrowFunction", "a ⇒ ", "Expected an expression");
292
292
  assertThrows("callExpression", "fn(a", "Expected right parenthesis");
@@ -298,6 +298,31 @@ describe("Origami parser", () => {
298
298
  assertThrows("templateLiteral", "`foo", "Expected closing backtick");
299
299
  });
300
300
 
301
+ test.only("error thrown for invalid Origami front matter expression", () => {
302
+ assertThrows(
303
+ "templateDocument",
304
+ `---
305
+ (name) => foo)
306
+ ---
307
+ Body`,
308
+ 'Expected "---"',
309
+ { line: 2, column: 14 }
310
+ );
311
+ });
312
+
313
+ test("error thrown for invalid YAML front matter", () => {
314
+ assertThrows(
315
+ "templateDocument",
316
+ `---
317
+ a : 1
318
+ }
319
+ ---
320
+ Body`,
321
+ "Unexpected flow-map-end token",
322
+ { line: 3, column: 1 }
323
+ );
324
+ });
325
+
301
326
  test("exponentiationExpression", () => {
302
327
  assertParse("exponentiationExpression", "2 ** 2 ** 3", [
303
328
  ops.exponentiation,
@@ -309,12 +334,10 @@ describe("Origami parser", () => {
309
334
  test("expression", () => {
310
335
  assertParse(
311
336
  "expression",
312
- `
313
- {
314
- index.html = index.ori(teamData.yaml)
315
- thumbnails = map(images, { value: thumbnail.js })
316
- }
317
- `,
337
+ `{
338
+ index.html = index.ori(teamData.yaml)
339
+ thumbnails = map(images, { value: thumbnail.js })
340
+ }`,
318
341
  [
319
342
  ops.object,
320
343
  [
@@ -1007,8 +1030,8 @@ describe("Origami parser", () => {
1007
1030
  ]);
1008
1031
  });
1009
1032
 
1010
- test("templateDocument", () => {
1011
- assertParse("templateDocument", "hello${foo}world", [
1033
+ test("templateBody", () => {
1034
+ assertParse("templateBody", "hello${foo}world", [
1012
1035
  ops.lambda,
1013
1036
  [[ops.literal, "_"]],
1014
1037
  [
@@ -1017,7 +1040,7 @@ describe("Origami parser", () => {
1017
1040
  [ops.concat, [ops.scope, "foo"]],
1018
1041
  ],
1019
1042
  ]);
1020
- assertParse("templateDocument", "Documents can contain ` backticks", [
1043
+ assertParse("templateBody", "Documents can contain ` backticks", [
1021
1044
  ops.lambda,
1022
1045
  [[ops.literal, "_"]],
1023
1046
  [
@@ -1027,6 +1050,66 @@ describe("Origami parser", () => {
1027
1050
  ]);
1028
1051
  });
1029
1052
 
1053
+ test("templateDocument with no front matter", () => {
1054
+ assertParse("templateDocument", "Hello, world!", [
1055
+ ops.lambda,
1056
+ [[ops.literal, "_"]],
1057
+ [ops.templateIndent, [ops.literal, ["Hello, world!"]]],
1058
+ ]);
1059
+ });
1060
+
1061
+ test("templateDocument with YAML front matter", () => {
1062
+ assertParse(
1063
+ "templateDocument",
1064
+ `---
1065
+ title: Title goes here
1066
+ ---
1067
+ Body text`,
1068
+ [
1069
+ ops.document,
1070
+ [ops.literal, { title: "Title goes here" }],
1071
+ [
1072
+ ops.lambda,
1073
+ [[ops.literal, "_"]],
1074
+ [ops.templateIndent, [ops.literal, ["Body text"]]],
1075
+ ],
1076
+ ]
1077
+ );
1078
+ });
1079
+
1080
+ test("templateDocument with Origami front matter", () => {
1081
+ assertParse(
1082
+ "templateDocument",
1083
+ `---
1084
+ {
1085
+ title: "Title"
1086
+ @text: @template()
1087
+ }
1088
+ ---
1089
+ <h1>\${ title }</h1>
1090
+ `,
1091
+ [
1092
+ ops.object,
1093
+ ["title", [ops.literal, "Title"]],
1094
+ [
1095
+ "@text",
1096
+ [
1097
+ [
1098
+ ops.lambda,
1099
+ [[ops.literal, "_"]],
1100
+ [
1101
+ ops.templateIndent,
1102
+ [ops.literal, ["<h1>", "</h1>\n"]],
1103
+ [ops.concat, [ops.scope, "title"]],
1104
+ ],
1105
+ ],
1106
+ undefined,
1107
+ ],
1108
+ ],
1109
+ ]
1110
+ );
1111
+ });
1112
+
1030
1113
  test("templateLiteral", () => {
1031
1114
  assertParse("templateLiteral", "`Hello, world.`", [
1032
1115
  ops.template,
@@ -1106,7 +1189,7 @@ function assertParse(startRule, source, expected, checkLocation = true) {
1106
1189
  code.location.start.offset,
1107
1190
  code.location.end.offset
1108
1191
  );
1109
- assert.equal(resultSource, source.trim());
1192
+ assert.equal(resultSource, source);
1110
1193
  }
1111
1194
 
1112
1195
  assertCodeEqual(code, expected);
@@ -1121,7 +1204,7 @@ function assertCodeLocations(code) {
1121
1204
  }
1122
1205
  }
1123
1206
 
1124
- function assertThrows(startRule, source, message) {
1207
+ function assertThrows(startRule, source, message, position) {
1125
1208
  try {
1126
1209
  parse(source, {
1127
1210
  grammarSource: { text: source },
@@ -1132,6 +1215,10 @@ function assertThrows(startRule, source, message) {
1132
1215
  error.message.includes(message),
1133
1216
  `Error message incorrect:\n expected: "${message}"\n actual: "${error.message}"`
1134
1217
  );
1218
+ if (position) {
1219
+ assert.equal(error.location.start.line, position.line);
1220
+ assert.equal(error.location.start.column, position.column);
1221
+ }
1135
1222
  return;
1136
1223
  }
1137
1224
  assert.fail(`Expected error: ${message}`);
@@ -80,6 +80,29 @@ describe("ops", () => {
80
80
  assert.strictEqual(await ops.conditional(false, errorFn, trueFn), true);
81
81
  });
82
82
 
83
+ test("ops.documentFunction", async () => {
84
+ const code = createCode([
85
+ ops.document,
86
+ {
87
+ a: 1,
88
+ },
89
+ [
90
+ ops.lambda,
91
+ [["_"]],
92
+ [
93
+ ops.template,
94
+ [ops.literal, ["a = ", ""]],
95
+ [ops.concat, [ops.scope, "a"]],
96
+ ],
97
+ ],
98
+ ]);
99
+ const result = await evaluate.call(null, code);
100
+ assert.deepEqual(result, {
101
+ a: 1,
102
+ "@text": "a = 1",
103
+ });
104
+ });
105
+
83
106
  test("ops.division divides two numbers", async () => {
84
107
  assert.strictEqual(ops.division(12, 2), 6);
85
108
  assert.strictEqual(ops.division(3, 2), 1.5);