@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.
- package/LICENSE +21 -0
- package/main.js +1 -0
- package/package.json +7 -7
- package/src/compiler/compile.js +43 -23
- package/src/compiler/origami.pegjs +22 -28
- package/src/compiler/parse.js +135 -153
- package/src/compiler/parserHelpers.js +18 -27
- package/src/runtime/errors.js +34 -12
- package/src/runtime/expressionObject.js +56 -13
- package/src/runtime/ops.js +10 -1
- package/src/runtime/taggedTemplate.js +1 -1
- package/src/runtime/taggedTemplateIndent.js +115 -0
- package/test/compiler/compile.test.js +19 -5
- package/test/compiler/parse.test.js +10 -7
- package/test/runtime/expressionObject.test.js +19 -1
- package/test/runtime/taggedTemplateIndent.test.js +44 -0
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import {
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|
package/src/runtime/ops.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
49
|
+
assert.deepEqual(await object.a.b, "Alice");
|
|
50
50
|
});
|
|
51
51
|
|
|
52
52
|
test("templateDocument", async () => {
|
|
53
|
-
const
|
|
54
|
-
|
|
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
|
|
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
|
-
[
|
|
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("
|
|
950
|
-
assertParse("
|
|
951
|
-
assertParse("
|
|
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.
|
|
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
|
-
[
|
|
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
|
+
});
|