@weborigami/language 0.2.1 → 0.2.3

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,4 +1,10 @@
1
- import { extension, ObjectTree, symbols, Tree } from "@weborigami/async-tree";
1
+ import {
2
+ extension,
3
+ ObjectTree,
4
+ symbols,
5
+ trailingSlash,
6
+ Tree,
7
+ } from "@weborigami/async-tree";
2
8
  import { handleExtension } from "./handlers.js";
3
9
  import { evaluate, ops } from "./internal.js";
4
10
 
@@ -11,8 +17,8 @@ import { evaluate, ops } from "./internal.js";
11
17
  *
12
18
  * 1. A primitive value (string, etc.). This will be defined directly as an
13
19
  * object property.
14
- * 1. An immediate code entry. This will be evaluated during this call and its
15
- * result defined as an object property.
20
+ * 1. An eager (as opposed to lazy) code entry. This will be evaluated during
21
+ * this call and its result defined as an object property.
16
22
  * 1. A code entry that starts with ops.getter. This will be defined as a
17
23
  * property getter on the object.
18
24
  *
@@ -25,15 +31,9 @@ export default async function expressionObject(entries, parent) {
25
31
  if (parent !== null && !Tree.isAsyncTree(parent)) {
26
32
  throw new TypeError(`Parent must be an AsyncTree or null`);
27
33
  }
28
- Object.defineProperty(object, symbols.parent, {
29
- configurable: true,
30
- enumerable: false,
31
- value: parent,
32
- writable: true,
33
- });
34
34
 
35
35
  let tree;
36
- const immediateProperties = [];
36
+ const eagerProperties = [];
37
37
  for (let [key, value] of entries) {
38
38
  // Determine if we need to define a getter or a regular property. If the key
39
39
  // has an extension, we need to define a getter. If the value is code (an
@@ -61,7 +61,6 @@ export default async function expressionObject(entries, parent) {
61
61
 
62
62
  if (defineProperty) {
63
63
  // Define simple property
64
- // object[key] = value;
65
64
  Object.defineProperty(object, key, {
66
65
  configurable: true,
67
66
  enumerable,
@@ -74,7 +73,7 @@ export default async function expressionObject(entries, parent) {
74
73
  if (value[0] === ops.getter) {
75
74
  code = value[1];
76
75
  } else {
77
- immediateProperties.push(key);
76
+ eagerProperties.push(key);
78
77
  code = value;
79
78
  }
80
79
 
@@ -102,9 +101,25 @@ export default async function expressionObject(entries, parent) {
102
101
  }
103
102
  }
104
103
 
104
+ // Attach a keys method
105
+ Object.defineProperty(object, symbols.keys, {
106
+ configurable: true,
107
+ enumerable: false,
108
+ value: () => keys(object, eagerProperties, entries),
109
+ writable: true,
110
+ });
111
+
112
+ // Attach the parent
113
+ Object.defineProperty(object, symbols.parent, {
114
+ configurable: true,
115
+ enumerable: false,
116
+ value: parent,
117
+ writable: true,
118
+ });
119
+
105
120
  // Evaluate any properties that were declared as immediate: get their value
106
121
  // and overwrite the property getter with the actual value.
107
- for (const key of immediateProperties) {
122
+ for (const key of eagerProperties) {
108
123
  const value = await object[key];
109
124
  // @ts-ignore Unclear why TS thinks `object` might be undefined here
110
125
  const enumerable = Object.getOwnPropertyDescriptor(object, key).enumerable;
@@ -118,3 +133,31 @@ export default async function expressionObject(entries, parent) {
118
133
 
119
134
  return object;
120
135
  }
136
+
137
+ function entryKey(object, eagerProperties, entry) {
138
+ const [key, value] = entry;
139
+
140
+ const hasExplicitSlash = trailingSlash.has(key);
141
+ if (hasExplicitSlash) {
142
+ // Return key as is
143
+ return key;
144
+ }
145
+
146
+ // If eager property value is treelike, add slash to the key
147
+ if (eagerProperties.includes(key) && Tree.isTreelike(object[key])) {
148
+ return trailingSlash.add(key);
149
+ }
150
+
151
+ // If entry will definitely create a subtree, add a trailing slash
152
+ const entryCreatesSubtree =
153
+ value instanceof Array &&
154
+ (value[0] === ops.object ||
155
+ (value[0] === ops.getter &&
156
+ value[1] instanceof Array &&
157
+ (value[1][0] === ops.object || value[1][0] === ops.merge)));
158
+ return trailingSlash.toggle(key, entryCreatesSubtree);
159
+ }
160
+
161
+ function keys(object, eagerProperties, entries) {
162
+ return entries.map((entry) => entryKey(object, eagerProperties, entry));
163
+ }
@@ -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) {
@@ -667,7 +667,7 @@ describe("Origami parser", () => {
667
667
  assertParse("objectLiteral", "{ a: { b = fn() } }", [
668
668
  ops.object,
669
669
  [
670
- "a/",
670
+ "a",
671
671
  [ops.object, ["b", [ops.getter, [[ops.builtin, "fn"], undefined]]]],
672
672
  ],
673
673
  ]);
@@ -687,7 +687,7 @@ describe("Origami parser", () => {
687
687
  assertParse("objectLiteral", "{ a: 1, ...b }", [
688
688
  ops.merge,
689
689
  [ops.object, ["a", [ops.literal, 1]]],
690
- [undetermined, "b"],
690
+ [ops.scope, "b"],
691
691
  ]);
692
692
  assertParse("objectLiteral", "{ (a): 1 }", [
693
693
  ops.object,
@@ -946,9 +946,9 @@ describe("Origami parser", () => {
946
946
  assertParse("singleLineComment", "// Hello, world!", null, false);
947
947
  });
948
948
 
949
- test("spread", () => {
950
- assertParse("spread", "...a", [ops.spread, [undetermined, "a"]]);
951
- assertParse("spread", "…a", [ops.spread, [undetermined, "a"]]);
949
+ test("spreadElement", () => {
950
+ assertParse("spreadElement", "...a", [ops.spread, [ops.scope, "a"]]);
951
+ assertParse("spreadElement", "…a", [ops.spread, [ops.scope, "a"]]);
952
952
  });
953
953
 
954
954
  test("stringLiteral", () => {
@@ -970,7 +970,7 @@ describe("Origami parser", () => {
970
970
  ops.lambda,
971
971
  ["_"],
972
972
  [
973
- ops.template,
973
+ ops.templateIndent,
974
974
  [ops.literal, ["hello", "world"]],
975
975
  [ops.concat, [ops.scope, "foo"]],
976
976
  ],
@@ -978,7 +978,10 @@ describe("Origami parser", () => {
978
978
  assertParse("templateDocument", "Documents can contain ` backticks", [
979
979
  ops.lambda,
980
980
  ["_"],
981
- [ops.template, [ops.literal, ["Documents can contain ` backticks"]]],
981
+ [
982
+ ops.templateIndent,
983
+ [ops.literal, ["Documents can contain ` backticks"]],
984
+ ],
982
985
  ]);
983
986
  });
984
987
 
@@ -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
  });
@@ -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
+ });