@weborigami/language 0.2.2 → 0.2.4

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.
@@ -12,6 +12,7 @@ import {
12
12
  concat as treeConcat,
13
13
  } from "@weborigami/async-tree";
14
14
  import os from "node:os";
15
+ import taggedTemplateIndent from "../../src/runtime/taggedTemplateIndent.js";
15
16
  import { builtinReferenceError, scopeReferenceError } from "./errors.js";
16
17
  import expressionObject from "./expressionObject.js";
17
18
  import { evaluate } from "./internal.js";
@@ -447,10 +448,18 @@ addOpLabel(subtraction, "«ops.subtraction»");
447
448
  * Apply the default tagged template function.
448
449
  */
449
450
  export function template(strings, ...values) {
450
- return taggedTemplate(strings, values);
451
+ return taggedTemplate(strings, ...values);
451
452
  }
452
453
  addOpLabel(template, "«ops.template»");
453
454
 
455
+ /**
456
+ * Apply the tagged template indent function.
457
+ */
458
+ export function templateIndent(strings, ...values) {
459
+ return taggedTemplateIndent(strings, ...values);
460
+ }
461
+ addOpLabel(templateIndent, "«ops.templateIndent");
462
+
454
463
  /**
455
464
  * Traverse a path of keys through a tree.
456
465
  */
@@ -1,6 +1,6 @@
1
1
  // Default JavaScript tagged template function splices strings and values
2
2
  // together.
3
- export default function defaultTemplateJoin(strings, values) {
3
+ export default function taggedTemplate(strings, ...values) {
4
4
  let result = strings[0];
5
5
  for (let i = 0; i < values.length; i++) {
6
6
  result += values[i] + strings[i + 1];
@@ -0,0 +1,115 @@
1
+ const lastLineWhitespaceRegex = /\n(?<indent>[ \t]*)$/;
2
+
3
+ const mapStringsToModifications = new Map();
4
+
5
+ /**
6
+ * Normalize indentation in a tagged template string.
7
+ *
8
+ * @param {TemplateStringsArray} strings
9
+ * @param {...any} values
10
+ * @returns {string}
11
+ */
12
+ export default function indent(strings, ...values) {
13
+ let modified = mapStringsToModifications.get(strings);
14
+ if (!modified) {
15
+ modified = modifyStrings(strings);
16
+ mapStringsToModifications.set(strings, modified);
17
+ }
18
+ const { blockIndentations, strings: modifiedStrings } = modified;
19
+ return joinBlocks(modifiedStrings, values, blockIndentations);
20
+ }
21
+
22
+ // Join strings and values, applying the given block indentation to the lines of
23
+ // values for block placholders.
24
+ function joinBlocks(strings, values, blockIndentations) {
25
+ let result = strings[0];
26
+ for (let i = 0; i < values.length; i++) {
27
+ let text = values[i];
28
+ if (text) {
29
+ const blockIndentation = blockIndentations[i];
30
+ if (blockIndentation) {
31
+ const lines = text.split("\n");
32
+ text = "";
33
+ if (lines.at(-1) === "") {
34
+ // Drop empty last line
35
+ lines.pop();
36
+ }
37
+ for (let line of lines) {
38
+ text += blockIndentation + line + "\n";
39
+ }
40
+ }
41
+ result += text;
42
+ }
43
+ result += strings[i + 1];
44
+ }
45
+ return result;
46
+ }
47
+
48
+ // Given an array of template boilerplate strings, return an object { modified,
49
+ // blockIndentations } where `strings` is the array of strings with indentation
50
+ // removed, and `blockIndentations` is an array of indentation strings for each
51
+ // block placeholder.
52
+ function modifyStrings(strings) {
53
+ // Phase one: Identify the indentation based on the first real line of the
54
+ // first string (skipping the initial newline), and remove this indentation
55
+ // from all lines of all strings.
56
+ let indent;
57
+ if (strings.length > 0 && strings[0].startsWith("\n")) {
58
+ // Look for indenttation
59
+ const firstLineWhitespaceRegex = /^\n(?<indent>[ \t]*)/;
60
+ const match = strings[0].match(firstLineWhitespaceRegex);
61
+ indent = match?.groups.indent;
62
+ }
63
+
64
+ // Determine the modified strings. If this invoked as a JS tagged template
65
+ // literal, the `strings` argument will be an odd array-ish object that we'll
66
+ // want to convert to a real array.
67
+ let modified;
68
+ if (indent) {
69
+ // De-indent the strings.
70
+ const indentationRegex = new RegExp(`\n${indent}`, "g");
71
+ // The `replaceAll` also converts strings to a real array.
72
+ modified = strings.map((string) =>
73
+ string.replaceAll(indentationRegex, "\n")
74
+ );
75
+ // Remove indentation from last line of last string
76
+ modified[modified.length - 1] = modified
77
+ .at(-1)
78
+ .replace(lastLineWhitespaceRegex, "\n");
79
+ } else {
80
+ // No indentation; just copy the strings so we have a real array
81
+ modified = strings.slice();
82
+ }
83
+
84
+ // Phase two: Identify any block placholders, identify and remove their
85
+ // preceding indentation, and remove the following newline. Work backward from
86
+ // the end towards the start because we're modifying the strings in place and
87
+ // our pattern matching won't work going forward from start to end.
88
+ let blockIndentations = [];
89
+ for (let i = modified.length - 2; i >= 0; i--) {
90
+ // Get the modified before and after substitution with index `i`
91
+ const beforeString = modified[i];
92
+ const afterString = modified[i + 1];
93
+ const match = beforeString.match(lastLineWhitespaceRegex);
94
+ if (match && afterString.startsWith("\n")) {
95
+ // The substitution between these strings is a block substitution
96
+ let blockIndentation = match.groups.indent;
97
+ blockIndentations[i] = blockIndentation;
98
+ // Trim the before and after strings
99
+ if (blockIndentation) {
100
+ modified[i] = beforeString.slice(0, -blockIndentation.length);
101
+ }
102
+ modified[i + 1] = afterString.slice(1);
103
+ }
104
+ }
105
+
106
+ // Remove newline from start of first string *after* removing indentation.
107
+ if (modified[0].startsWith("\n")) {
108
+ modified[0] = modified[0].slice(1);
109
+ }
110
+
111
+ return {
112
+ blockIndentations,
113
+ strings: modified,
114
+ };
115
+ }
@@ -10,7 +10,7 @@ const shared = new ObjectTree({
10
10
  name: "Alice",
11
11
  });
12
12
 
13
- describe.only("compile", () => {
13
+ describe("compile", () => {
14
14
  test("array", async () => {
15
15
  await assertCompile("[]", []);
16
16
  await assertCompile("[ 1, 2, 3, ]", [1, 2, 3]);
@@ -46,12 +46,14 @@ describe.only("compile", () => {
46
46
  test("async object", async () => {
47
47
  const fn = compile.expression("{ a: { b = name }}");
48
48
  const object = await fn.call(shared);
49
- assert.deepEqual(await object["a/"].b, "Alice");
49
+ assert.deepEqual(await object.a.b, "Alice");
50
50
  });
51
51
 
52
52
  test("templateDocument", async () => {
53
- const fn = compile.templateDocument("Documents can contain ` backticks");
54
- const templateFn = await fn.call(shared);
53
+ const defineTemplateFn = compile.templateDocument(
54
+ "Documents can contain ` backticks"
55
+ );
56
+ const templateFn = await defineTemplateFn.call(null);
55
57
  const value = await templateFn.call(null);
56
58
  assert.deepEqual(value, "Documents can contain ` backticks");
57
59
  });
@@ -85,7 +87,7 @@ describe.only("compile", () => {
85
87
  assert.equal(bob, "Hello, Bob!");
86
88
  });
87
89
 
88
- test.only("converts non-local ops.scope calls to ops.external", async () => {
90
+ test("converts non-local ops.scope calls to ops.external", async () => {
89
91
  const expression = `
90
92
  (name) => {
91
93
  a: 1
@@ -108,6 +110,18 @@ describe.only("compile", () => {
108
110
  ],
109
111
  ]);
110
112
  });
113
+
114
+ test("can apply a macro", async () => {
115
+ const literal = [ops.literal, 1];
116
+ const expression = `{ a: literal }`;
117
+ const fn = compile.expression(expression, {
118
+ macros: {
119
+ literal,
120
+ },
121
+ });
122
+ const code = fn.code;
123
+ assert.deepEqual(stripCodeLocations(code), [ops.object, ["a", literal]]);
124
+ });
111
125
  });
112
126
 
113
127
  async function assertCompile(text, expected) {
@@ -433,6 +433,18 @@ describe("Origami parser", () => {
433
433
  [ops.scope, "slug"],
434
434
  ],
435
435
  ]);
436
+ assertParse("expression", "keys ~", [
437
+ [ops.builtin, "keys"],
438
+ [ops.homeDirectory],
439
+ ]);
440
+ assertParse("expression", "keys /Users/alice", [
441
+ [ops.builtin, "keys"],
442
+ [
443
+ ops.traverse,
444
+ [ops.rootDirectory, [ops.literal, "Users/"]],
445
+ [ops.literal, "alice"],
446
+ ],
447
+ ]);
436
448
 
437
449
  // Verify parser treatment of identifiers containing operators
438
450
  assertParse("expression", "a + b", [
@@ -667,7 +679,7 @@ describe("Origami parser", () => {
667
679
  assertParse("objectLiteral", "{ a: { b = fn() } }", [
668
680
  ops.object,
669
681
  [
670
- "a/",
682
+ "a",
671
683
  [ops.object, ["b", [ops.getter, [[ops.builtin, "fn"], undefined]]]],
672
684
  ],
673
685
  ]);
@@ -970,7 +982,7 @@ describe("Origami parser", () => {
970
982
  ops.lambda,
971
983
  ["_"],
972
984
  [
973
- ops.template,
985
+ ops.templateIndent,
974
986
  [ops.literal, ["hello", "world"]],
975
987
  [ops.concat, [ops.scope, "foo"]],
976
988
  ],
@@ -978,7 +990,10 @@ describe("Origami parser", () => {
978
990
  assertParse("templateDocument", "Documents can contain ` backticks", [
979
991
  ops.lambda,
980
992
  ["_"],
981
- [ops.template, [ops.literal, ["Documents can contain ` backticks"]]],
993
+ [
994
+ ops.templateIndent,
995
+ [ops.literal, ["Documents can contain ` backticks"]],
996
+ ],
982
997
  ]);
983
998
  });
984
999
 
@@ -5,7 +5,7 @@ import { describe, test } from "node:test";
5
5
  import expressionObject from "../../src/runtime/expressionObject.js";
6
6
  import { ops } from "../../src/runtime/internal.js";
7
7
 
8
- describe("expressionObject", () => {
8
+ describe.only("expressionObject", () => {
9
9
  test("can instantiate an object", async () => {
10
10
  const scope = new ObjectTree({
11
11
  upper: (s) => s.toUpperCase(),
@@ -73,4 +73,22 @@ describe("expressionObject", () => {
73
73
  assert.deepEqual(Object.keys(object), ["visible"]);
74
74
  assert.equal(object["hidden"], "shh");
75
75
  });
76
+
77
+ test.only("provides a symbols.keys method", async () => {
78
+ const entries = [
79
+ // Will return a tree, should have a slash
80
+ ["getter", [ops.getter, [ops.object, ["b", [ops.literal, 2]]]]],
81
+ ["hasSlash/", "This isn't really a tree but says it is"],
82
+ ["message", "Hello"],
83
+ // Immediate treelike value, should have a slash
84
+ ["object", [ops.object, ["b", [ops.literal, 2]]]],
85
+ ];
86
+ const object = await expressionObject(entries, null);
87
+ assert.deepEqual(object[symbols.keys](), [
88
+ "getter/",
89
+ "hasSlash/",
90
+ "message",
91
+ "object/",
92
+ ]);
93
+ });
76
94
  });
@@ -1,50 +1,68 @@
1
- import { Tree } from "@weborigami/async-tree";
1
+ import { ObjectTree, Tree } from "@weborigami/async-tree";
2
2
  import assert from "node:assert";
3
3
  import { describe, test } from "node:test";
4
4
  import mergeTrees from "../../src/runtime/mergeTrees.js";
5
5
 
6
6
  describe("mergeTrees", () => {
7
- test("merges trees", async () => {
8
- const tree = await mergeTrees.call(
7
+ test("if all arguments are plain objects, result is a plain object", async () => {
8
+ let calledFoo = false;
9
+ let calledBar = false;
10
+ const result = await mergeTrees.call(
9
11
  null,
10
12
  {
11
13
  a: 1,
12
14
  b: 2,
15
+ get foo() {
16
+ calledFoo = true;
17
+ return true;
18
+ },
13
19
  },
14
20
  {
15
21
  b: 3,
16
22
  c: 4,
23
+ get bar() {
24
+ calledBar = true;
25
+ return true;
26
+ },
17
27
  }
18
28
  );
19
- // @ts-ignore
20
- assert.deepEqual(await Tree.plain(tree), {
29
+
30
+ // Shouldn't call functions when just getting keys
31
+ assert.deepEqual(Object.keys(result), ["a", "b", "foo", "c", "bar"]);
32
+ assert(!calledFoo);
33
+ assert(!calledBar);
34
+
35
+ assert.deepEqual(result, {
21
36
  a: 1,
22
37
  b: 3,
38
+ foo: true,
23
39
  c: 4,
40
+ bar: true,
24
41
  });
25
42
  });
26
43
 
27
- test("if all arguments are plain objects, result is a plain object", async () => {
28
- const result = await mergeTrees.call(
44
+ test("if all arguments are arrays, result is an array", async () => {
45
+ const result = await mergeTrees.call(null, [1, 2], [3, 4]);
46
+ assert.deepEqual(result, [1, 2, 3, 4]);
47
+ });
48
+
49
+ test("merges heterogenous arguments as trees", async () => {
50
+ const tree = await mergeTrees.call(
29
51
  null,
30
- {
52
+ new ObjectTree({
31
53
  a: 1,
32
54
  b: 2,
33
- },
55
+ }),
34
56
  {
35
57
  b: 3,
36
58
  c: 4,
37
59
  }
38
60
  );
39
- assert.deepEqual(result, {
61
+ // @ts-ignore
62
+ assert.deepEqual(await Tree.plain(tree), {
40
63
  a: 1,
41
64
  b: 3,
42
65
  c: 4,
43
66
  });
44
67
  });
45
-
46
- test("if all arguments are arrays, result is an array", async () => {
47
- const result = await mergeTrees.call(null, [1, 2], [3, 4]);
48
- assert.deepEqual(result, [1, 2, 3, 4]);
49
- });
50
68
  });
@@ -0,0 +1,44 @@
1
+ import assert from "node:assert";
2
+ import { describe, test } from "node:test";
3
+ import indent from "../../src/runtime/taggedTemplateIndent.js";
4
+
5
+ describe("taggedTemplateIndent", () => {
6
+ test("joins strings and values together if template isn't a block template", () => {
7
+ const result = indent`a ${"b"} c`;
8
+ assert.equal(result, "a b c");
9
+ });
10
+
11
+ test("removes first and last lines if template is a block template", () => {
12
+ const actual = indent`
13
+ <p>
14
+ Hello, ${"Alice"}!
15
+ </p>
16
+ `;
17
+ const expected = `
18
+ <p>
19
+ Hello, Alice!
20
+ </p>
21
+ `.trimStart();
22
+ assert.equal(actual, expected);
23
+ });
24
+
25
+ test("indents all lines in a block substitution", () => {
26
+ const lines = `
27
+ Line 1
28
+ Line 2
29
+ Line 3`.trimStart();
30
+ const actual = indent`
31
+ <main>
32
+ ${lines}
33
+ </main>
34
+ `;
35
+ const expected = `
36
+ <main>
37
+ Line 1
38
+ Line 2
39
+ Line 3
40
+ </main>
41
+ `.trimStart();
42
+ assert.equal(actual, expected);
43
+ });
44
+ });