@checkstack/template-engine 0.2.0
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/CHANGELOG.md +114 -0
- package/package.json +30 -0
- package/src/__tests__/parser.test.ts +74 -0
- package/src/__tests__/renderer.test.ts +183 -0
- package/src/ast.ts +101 -0
- package/src/errors.ts +74 -0
- package/src/filters.ts +145 -0
- package/src/index.ts +6 -0
- package/src/parser.ts +340 -0
- package/src/renderer.ts +274 -0
- package/src/tokenizer.ts +338 -0
- package/src/types.ts +83 -0
- package/tsconfig.json +11 -0
package/src/renderer.ts
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import type { Expr, TemplateNode } from "./ast";
|
|
2
|
+
import {
|
|
3
|
+
TemplateRenderError,
|
|
4
|
+
UnknownFilterError,
|
|
5
|
+
} from "./errors";
|
|
6
|
+
import {
|
|
7
|
+
createDefaultFilterRegistry,
|
|
8
|
+
isTruthy,
|
|
9
|
+
} from "./filters";
|
|
10
|
+
import type {
|
|
11
|
+
FilterRegistry,
|
|
12
|
+
ParsedCondition,
|
|
13
|
+
ParsedTemplate,
|
|
14
|
+
SourceRange,
|
|
15
|
+
TemplateContext,
|
|
16
|
+
} from "./types";
|
|
17
|
+
|
|
18
|
+
export interface RenderOptions {
|
|
19
|
+
/**
|
|
20
|
+
* Custom filter registry. Defaults to the built-in registry. Pass a
|
|
21
|
+
* pre-loaded registry when integrating with extra plugin-contributed
|
|
22
|
+
* filters.
|
|
23
|
+
*/
|
|
24
|
+
filters?: FilterRegistry;
|
|
25
|
+
/**
|
|
26
|
+
* When true, accessing an undefined path throws instead of resolving to
|
|
27
|
+
* the empty string. Default: false (graceful missing → empty string).
|
|
28
|
+
* The dispatch engine flips this on at certain audit-critical sites.
|
|
29
|
+
*/
|
|
30
|
+
strict?: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Render a parsed template to a string. Handles literal segments and
|
|
35
|
+
* `{{ expression }}` interpolation. Unknown identifiers resolve to the
|
|
36
|
+
* empty string by default (operators see this as a missing value).
|
|
37
|
+
*/
|
|
38
|
+
export function render(
|
|
39
|
+
template: ParsedTemplate,
|
|
40
|
+
context: TemplateContext,
|
|
41
|
+
options: RenderOptions = {},
|
|
42
|
+
): string {
|
|
43
|
+
const filters = options.filters ?? createDefaultFilterRegistry();
|
|
44
|
+
let out = "";
|
|
45
|
+
const nodes = template.nodes as TemplateNode[];
|
|
46
|
+
for (const node of nodes) {
|
|
47
|
+
if (node.kind === "text") {
|
|
48
|
+
out += node.value;
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
const value = evaluateExpr(
|
|
52
|
+
node.expression,
|
|
53
|
+
context,
|
|
54
|
+
template.source,
|
|
55
|
+
filters,
|
|
56
|
+
options,
|
|
57
|
+
);
|
|
58
|
+
out += stringify(value);
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Evaluate a parsed condition expression against context and return the
|
|
65
|
+
* raw value. Use `isTruthy()` to coerce to boolean if needed.
|
|
66
|
+
*/
|
|
67
|
+
export function evaluate(
|
|
68
|
+
condition: ParsedCondition,
|
|
69
|
+
context: TemplateContext,
|
|
70
|
+
options: RenderOptions = {},
|
|
71
|
+
): unknown {
|
|
72
|
+
const filters = options.filters ?? createDefaultFilterRegistry();
|
|
73
|
+
return evaluateExpr(
|
|
74
|
+
condition.root,
|
|
75
|
+
context,
|
|
76
|
+
condition.source,
|
|
77
|
+
filters,
|
|
78
|
+
options,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Evaluate a condition and coerce to boolean using the engine's truthiness
|
|
84
|
+
* rule. Convenience over `evaluate()` for the common case.
|
|
85
|
+
*/
|
|
86
|
+
export function evaluateBoolean(
|
|
87
|
+
condition: ParsedCondition,
|
|
88
|
+
context: TemplateContext,
|
|
89
|
+
options: RenderOptions = {},
|
|
90
|
+
): boolean {
|
|
91
|
+
return isTruthy(evaluate(condition, context, options));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ─── Internal evaluator ────────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
function evaluateExpr(
|
|
97
|
+
expr: Expr,
|
|
98
|
+
context: TemplateContext,
|
|
99
|
+
source: string,
|
|
100
|
+
filters: FilterRegistry,
|
|
101
|
+
options: RenderOptions,
|
|
102
|
+
): unknown {
|
|
103
|
+
switch (expr.kind) {
|
|
104
|
+
case "literal": {
|
|
105
|
+
return expr.value;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
case "identifier": {
|
|
109
|
+
return resolveIdentifier(expr.name, context, expr.range, source, options);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
case "member": {
|
|
113
|
+
const obj = evaluateExpr(expr.object, context, source, filters, options);
|
|
114
|
+
return resolveMember(obj, expr.property, expr.range, source, options);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
case "index": {
|
|
118
|
+
const obj = evaluateExpr(expr.object, context, source, filters, options);
|
|
119
|
+
const key = evaluateExpr(expr.index, context, source, filters, options);
|
|
120
|
+
return resolveMember(obj, String(key ?? ""), expr.range, source, options);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
case "unary": {
|
|
124
|
+
return !isTruthy(
|
|
125
|
+
evaluateExpr(expr.operand, context, source, filters, options),
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
case "binary": {
|
|
130
|
+
const left = evaluateExpr(expr.left, context, source, filters, options);
|
|
131
|
+
// Short-circuit logical operators
|
|
132
|
+
if (expr.op === "&&") {
|
|
133
|
+
if (!isTruthy(left)) return left;
|
|
134
|
+
return evaluateExpr(expr.right, context, source, filters, options);
|
|
135
|
+
}
|
|
136
|
+
if (expr.op === "||") {
|
|
137
|
+
if (isTruthy(left)) return left;
|
|
138
|
+
return evaluateExpr(expr.right, context, source, filters, options);
|
|
139
|
+
}
|
|
140
|
+
const right = evaluateExpr(expr.right, context, source, filters, options);
|
|
141
|
+
return applyComparison(expr.op, left, right);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
case "ternary": {
|
|
145
|
+
return isTruthy(
|
|
146
|
+
evaluateExpr(expr.condition, context, source, filters, options),
|
|
147
|
+
)
|
|
148
|
+
? evaluateExpr(expr.consequent, context, source, filters, options)
|
|
149
|
+
: evaluateExpr(expr.alternate, context, source, filters, options);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
case "pipe": {
|
|
153
|
+
const value = evaluateExpr(expr.value, context, source, filters, options);
|
|
154
|
+
const filter = filters.get(expr.filter);
|
|
155
|
+
if (!filter) {
|
|
156
|
+
throw new UnknownFilterError({
|
|
157
|
+
filterName: expr.filter,
|
|
158
|
+
source,
|
|
159
|
+
range: expr.range,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
const args = expr.args.map((a) =>
|
|
163
|
+
evaluateExpr(a, context, source, filters, options),
|
|
164
|
+
);
|
|
165
|
+
return filter(value, ...args);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function resolveIdentifier(
|
|
171
|
+
name: string,
|
|
172
|
+
context: TemplateContext,
|
|
173
|
+
range: SourceRange,
|
|
174
|
+
source: string,
|
|
175
|
+
options: RenderOptions,
|
|
176
|
+
): unknown {
|
|
177
|
+
if (Object.prototype.hasOwnProperty.call(context, name)) {
|
|
178
|
+
return (context as Record<string, unknown>)[name];
|
|
179
|
+
}
|
|
180
|
+
if (options.strict) {
|
|
181
|
+
throw new TemplateRenderError({
|
|
182
|
+
message: `Unknown reference "${name}"`,
|
|
183
|
+
source,
|
|
184
|
+
range,
|
|
185
|
+
path: name,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
return undefined;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function resolveMember(
|
|
192
|
+
object: unknown,
|
|
193
|
+
property: string,
|
|
194
|
+
range: SourceRange,
|
|
195
|
+
source: string,
|
|
196
|
+
options: RenderOptions,
|
|
197
|
+
): unknown {
|
|
198
|
+
if (object === null || object === undefined) {
|
|
199
|
+
if (options.strict) {
|
|
200
|
+
throw new TemplateRenderError({
|
|
201
|
+
message: `Cannot access "${property}" of ${object === null ? "null" : "undefined"}`,
|
|
202
|
+
source,
|
|
203
|
+
range,
|
|
204
|
+
path: property,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
return undefined;
|
|
208
|
+
}
|
|
209
|
+
if (typeof object !== "object") return undefined;
|
|
210
|
+
return (object as Record<string, unknown>)[property];
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function applyComparison(
|
|
214
|
+
op: "==" | "!=" | "<" | ">" | "<=" | ">=",
|
|
215
|
+
left: unknown,
|
|
216
|
+
right: unknown,
|
|
217
|
+
): boolean {
|
|
218
|
+
switch (op) {
|
|
219
|
+
case "==": {
|
|
220
|
+
return looseEquals(left, right);
|
|
221
|
+
}
|
|
222
|
+
case "!=": {
|
|
223
|
+
return !looseEquals(left, right);
|
|
224
|
+
}
|
|
225
|
+
case "<": {
|
|
226
|
+
return compareNumeric(left, right) < 0;
|
|
227
|
+
}
|
|
228
|
+
case ">": {
|
|
229
|
+
return compareNumeric(left, right) > 0;
|
|
230
|
+
}
|
|
231
|
+
case "<=": {
|
|
232
|
+
return compareNumeric(left, right) <= 0;
|
|
233
|
+
}
|
|
234
|
+
case ">=": {
|
|
235
|
+
return compareNumeric(left, right) >= 0;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Equality used by `==`. Strings compare by content, numbers by value,
|
|
242
|
+
* and across types coerced to string when one side is a string. Mirrors
|
|
243
|
+
* what operators expect when comparing event ids to literals.
|
|
244
|
+
*/
|
|
245
|
+
function looseEquals(a: unknown, b: unknown): boolean {
|
|
246
|
+
if (a === b) return true;
|
|
247
|
+
if (a === null || a === undefined) return a === b;
|
|
248
|
+
if (b === null || b === undefined) return false;
|
|
249
|
+
if (typeof a === typeof b) return a === b;
|
|
250
|
+
return String(a) === String(b);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function compareNumeric(a: unknown, b: unknown): number {
|
|
254
|
+
const na = typeof a === "number" ? a : Number(a);
|
|
255
|
+
const nb = typeof b === "number" ? b : Number(b);
|
|
256
|
+
if (Number.isFinite(na) && Number.isFinite(nb)) {
|
|
257
|
+
if (na < nb) return -1;
|
|
258
|
+
if (na > nb) return 1;
|
|
259
|
+
return 0;
|
|
260
|
+
}
|
|
261
|
+
const sa = String(a);
|
|
262
|
+
const sb = String(b);
|
|
263
|
+
if (sa < sb) return -1;
|
|
264
|
+
if (sa > sb) return 1;
|
|
265
|
+
return 0;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function stringify(value: unknown): string {
|
|
269
|
+
if (value === null || value === undefined) return "";
|
|
270
|
+
if (typeof value === "string") return value;
|
|
271
|
+
if (typeof value === "number" || typeof value === "boolean")
|
|
272
|
+
return String(value);
|
|
273
|
+
return JSON.stringify(value);
|
|
274
|
+
}
|
package/src/tokenizer.ts
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
import { TemplateParseError, pointRange } from "./errors";
|
|
2
|
+
import type { SourcePosition, SourceRange } from "./types";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Tokens produced by the template tokenizer.
|
|
6
|
+
*
|
|
7
|
+
* Templates are a sequence of LITERAL text segments interleaved with
|
|
8
|
+
* `{{ ... }}` expression blocks. Inside an expression block the tokens
|
|
9
|
+
* are typed (idents, literals, operators).
|
|
10
|
+
*/
|
|
11
|
+
export type TokenKind =
|
|
12
|
+
// Top-level segments
|
|
13
|
+
| "LITERAL" // text outside {{ }}
|
|
14
|
+
| "EXPR_OPEN" // {{
|
|
15
|
+
| "EXPR_CLOSE" // }}
|
|
16
|
+
// Inside an expression
|
|
17
|
+
| "IDENT"
|
|
18
|
+
| "NUMBER"
|
|
19
|
+
| "STRING"
|
|
20
|
+
| "TRUE"
|
|
21
|
+
| "FALSE"
|
|
22
|
+
| "NULL"
|
|
23
|
+
| "DOT"
|
|
24
|
+
| "COMMA"
|
|
25
|
+
| "LPAREN"
|
|
26
|
+
| "RPAREN"
|
|
27
|
+
| "LBRACKET"
|
|
28
|
+
| "RBRACKET"
|
|
29
|
+
| "QUESTION"
|
|
30
|
+
| "COLON"
|
|
31
|
+
| "PIPE"
|
|
32
|
+
| "EQ"
|
|
33
|
+
| "NEQ"
|
|
34
|
+
| "LT"
|
|
35
|
+
| "GT"
|
|
36
|
+
| "LTE"
|
|
37
|
+
| "GTE"
|
|
38
|
+
| "AND"
|
|
39
|
+
| "OR"
|
|
40
|
+
| "NOT"
|
|
41
|
+
| "EOF";
|
|
42
|
+
|
|
43
|
+
export interface Token {
|
|
44
|
+
kind: TokenKind;
|
|
45
|
+
value: string;
|
|
46
|
+
range: SourceRange;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Tokenizer state machine.
|
|
51
|
+
*
|
|
52
|
+
* Switches between "literal" mode (collecting raw text) and "expression"
|
|
53
|
+
* mode (lexing identifiers, operators, etc.). The mode flips on `{{` and
|
|
54
|
+
* back on `}}`.
|
|
55
|
+
*/
|
|
56
|
+
export class Tokenizer {
|
|
57
|
+
private pos = 0;
|
|
58
|
+
private line = 1;
|
|
59
|
+
private column = 1;
|
|
60
|
+
private mode: "literal" | "expression" = "literal";
|
|
61
|
+
|
|
62
|
+
constructor(private readonly source: string) {}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Drive the tokenizer to completion and return all tokens. Empty LITERAL
|
|
66
|
+
* tokens are skipped — they add nothing but noise.
|
|
67
|
+
*/
|
|
68
|
+
tokenize(): Token[] {
|
|
69
|
+
const tokens: Token[] = [];
|
|
70
|
+
while (this.pos < this.source.length) {
|
|
71
|
+
const token = this.next();
|
|
72
|
+
if (token.kind === "LITERAL" && token.value.length === 0) continue;
|
|
73
|
+
tokens.push(token);
|
|
74
|
+
}
|
|
75
|
+
tokens.push({
|
|
76
|
+
kind: "EOF",
|
|
77
|
+
value: "",
|
|
78
|
+
range: pointRange(this.position()),
|
|
79
|
+
});
|
|
80
|
+
return tokens;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private next(): Token {
|
|
84
|
+
if (this.mode === "literal") {
|
|
85
|
+
return this.readLiteral();
|
|
86
|
+
}
|
|
87
|
+
return this.readExpression();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
private readLiteral(): Token {
|
|
91
|
+
const start = this.position();
|
|
92
|
+
let value = "";
|
|
93
|
+
while (this.pos < this.source.length) {
|
|
94
|
+
// Check for {{ — start of expression
|
|
95
|
+
if (this.peek() === "{" && this.peekAt(1) === "{") {
|
|
96
|
+
if (value.length === 0) {
|
|
97
|
+
// No literal before — emit EXPR_OPEN
|
|
98
|
+
const openStart = this.position();
|
|
99
|
+
this.advance();
|
|
100
|
+
this.advance();
|
|
101
|
+
this.mode = "expression";
|
|
102
|
+
return {
|
|
103
|
+
kind: "EXPR_OPEN",
|
|
104
|
+
value: "{{",
|
|
105
|
+
range: { start: openStart, end: this.position() },
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
// Emit accumulated literal; next call will see {{
|
|
109
|
+
return {
|
|
110
|
+
kind: "LITERAL",
|
|
111
|
+
value,
|
|
112
|
+
range: { start, end: this.position() },
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
value += this.peek();
|
|
116
|
+
this.advance();
|
|
117
|
+
}
|
|
118
|
+
return {
|
|
119
|
+
kind: "LITERAL",
|
|
120
|
+
value,
|
|
121
|
+
range: { start, end: this.position() },
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
private readExpression(): Token {
|
|
126
|
+
this.skipWhitespace();
|
|
127
|
+
if (this.pos >= this.source.length) {
|
|
128
|
+
throw new TemplateParseError({
|
|
129
|
+
message: "Unterminated expression: missing closing `}}`",
|
|
130
|
+
source: this.source,
|
|
131
|
+
range: pointRange(this.position()),
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
const start = this.position();
|
|
135
|
+
|
|
136
|
+
// Closing braces }}
|
|
137
|
+
if (this.peek() === "}" && this.peekAt(1) === "}") {
|
|
138
|
+
this.advance();
|
|
139
|
+
this.advance();
|
|
140
|
+
this.mode = "literal";
|
|
141
|
+
return {
|
|
142
|
+
kind: "EXPR_CLOSE",
|
|
143
|
+
value: "}}",
|
|
144
|
+
range: { start, end: this.position() },
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const c = this.peek();
|
|
149
|
+
|
|
150
|
+
// Numeric literal
|
|
151
|
+
if (this.isDigit(c)) {
|
|
152
|
+
return this.readNumber(start);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// String literal
|
|
156
|
+
if (c === '"' || c === "'") {
|
|
157
|
+
return this.readString(start, c);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Identifier / keyword
|
|
161
|
+
if (this.isIdentStart(c)) {
|
|
162
|
+
return this.readIdentifier(start);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Operators & punctuation
|
|
166
|
+
return this.readOperator(start);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
private readNumber(start: SourcePosition): Token {
|
|
170
|
+
let value = "";
|
|
171
|
+
while (this.pos < this.source.length && this.isDigit(this.peek())) {
|
|
172
|
+
value += this.peek();
|
|
173
|
+
this.advance();
|
|
174
|
+
}
|
|
175
|
+
if (this.peek() === "." && this.isDigit(this.peekAt(1) ?? "")) {
|
|
176
|
+
value += ".";
|
|
177
|
+
this.advance();
|
|
178
|
+
while (this.pos < this.source.length && this.isDigit(this.peek())) {
|
|
179
|
+
value += this.peek();
|
|
180
|
+
this.advance();
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return {
|
|
184
|
+
kind: "NUMBER",
|
|
185
|
+
value,
|
|
186
|
+
range: { start, end: this.position() },
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private readString(start: SourcePosition, quote: string): Token {
|
|
191
|
+
this.advance(); // skip opening quote
|
|
192
|
+
let value = "";
|
|
193
|
+
while (this.pos < this.source.length && this.peek() !== quote) {
|
|
194
|
+
if (this.peek() === "\\") {
|
|
195
|
+
this.advance();
|
|
196
|
+
const esc = this.peek();
|
|
197
|
+
const escMap: Record<string, string> = {
|
|
198
|
+
n: "\n",
|
|
199
|
+
t: "\t",
|
|
200
|
+
r: "\r",
|
|
201
|
+
"\\": "\\",
|
|
202
|
+
'"': '"',
|
|
203
|
+
"'": "'",
|
|
204
|
+
};
|
|
205
|
+
value += escMap[esc] ?? esc;
|
|
206
|
+
this.advance();
|
|
207
|
+
} else {
|
|
208
|
+
value += this.peek();
|
|
209
|
+
this.advance();
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
if (this.pos >= this.source.length) {
|
|
213
|
+
throw new TemplateParseError({
|
|
214
|
+
message: "Unterminated string literal",
|
|
215
|
+
source: this.source,
|
|
216
|
+
range: { start, end: this.position() },
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
this.advance(); // closing quote
|
|
220
|
+
return {
|
|
221
|
+
kind: "STRING",
|
|
222
|
+
value,
|
|
223
|
+
range: { start, end: this.position() },
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
private readIdentifier(start: SourcePosition): Token {
|
|
228
|
+
let value = "";
|
|
229
|
+
while (this.pos < this.source.length && this.isIdentCont(this.peek())) {
|
|
230
|
+
value += this.peek();
|
|
231
|
+
this.advance();
|
|
232
|
+
}
|
|
233
|
+
const range = { start, end: this.position() };
|
|
234
|
+
if (value === "true") return { kind: "TRUE", value, range };
|
|
235
|
+
if (value === "false") return { kind: "FALSE", value, range };
|
|
236
|
+
if (value === "null") return { kind: "NULL", value, range };
|
|
237
|
+
return { kind: "IDENT", value, range };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
private readOperator(start: SourcePosition): Token {
|
|
241
|
+
const c = this.peek();
|
|
242
|
+
const next = this.peekAt(1);
|
|
243
|
+
|
|
244
|
+
const two = c + (next ?? "");
|
|
245
|
+
const twoMap: Record<string, TokenKind> = {
|
|
246
|
+
"==": "EQ",
|
|
247
|
+
"!=": "NEQ",
|
|
248
|
+
"<=": "LTE",
|
|
249
|
+
">=": "GTE",
|
|
250
|
+
"&&": "AND",
|
|
251
|
+
"||": "OR",
|
|
252
|
+
};
|
|
253
|
+
if (twoMap[two]) {
|
|
254
|
+
this.advance();
|
|
255
|
+
this.advance();
|
|
256
|
+
return {
|
|
257
|
+
kind: twoMap[two],
|
|
258
|
+
value: two,
|
|
259
|
+
range: { start, end: this.position() },
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const oneMap: Record<string, TokenKind> = {
|
|
264
|
+
".": "DOT",
|
|
265
|
+
",": "COMMA",
|
|
266
|
+
"(": "LPAREN",
|
|
267
|
+
")": "RPAREN",
|
|
268
|
+
"[": "LBRACKET",
|
|
269
|
+
"]": "RBRACKET",
|
|
270
|
+
"?": "QUESTION",
|
|
271
|
+
":": "COLON",
|
|
272
|
+
"|": "PIPE",
|
|
273
|
+
"<": "LT",
|
|
274
|
+
">": "GT",
|
|
275
|
+
"!": "NOT",
|
|
276
|
+
};
|
|
277
|
+
if (oneMap[c]) {
|
|
278
|
+
this.advance();
|
|
279
|
+
return {
|
|
280
|
+
kind: oneMap[c],
|
|
281
|
+
value: c,
|
|
282
|
+
range: { start, end: this.position() },
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
throw new TemplateParseError({
|
|
287
|
+
message: `Unexpected character "${c}" in expression`,
|
|
288
|
+
source: this.source,
|
|
289
|
+
range: pointRange(start),
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
private skipWhitespace(): void {
|
|
294
|
+
while (this.pos < this.source.length && /\s/.test(this.peek())) {
|
|
295
|
+
this.advance();
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
private peek(): string {
|
|
300
|
+
return this.source[this.pos] ?? "";
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
private peekAt(offset: number): string | undefined {
|
|
304
|
+
return this.source[this.pos + offset];
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
private advance(): void {
|
|
308
|
+
const c = this.source[this.pos];
|
|
309
|
+
this.pos += 1;
|
|
310
|
+
if (c === "\n") {
|
|
311
|
+
this.line += 1;
|
|
312
|
+
this.column = 1;
|
|
313
|
+
} else {
|
|
314
|
+
this.column += 1;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
private position(): SourcePosition {
|
|
319
|
+
return { line: this.line, column: this.column };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
private isDigit(c: string): boolean {
|
|
323
|
+
return c >= "0" && c <= "9";
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
private isIdentStart(c: string): boolean {
|
|
327
|
+
return (
|
|
328
|
+
(c >= "a" && c <= "z") ||
|
|
329
|
+
(c >= "A" && c <= "Z") ||
|
|
330
|
+
c === "_" ||
|
|
331
|
+
c === "$"
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
private isIdentCont(c: string): boolean {
|
|
336
|
+
return this.isIdentStart(c) || this.isDigit(c);
|
|
337
|
+
}
|
|
338
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public types for the template engine.
|
|
3
|
+
*
|
|
4
|
+
* The template engine is used in two contexts:
|
|
5
|
+
*
|
|
6
|
+
* 1. Server-side at automation dispatch time — render the actual values
|
|
7
|
+
* for provider config fields, choose conditions, condition guards, etc.
|
|
8
|
+
* 2. Client-side at edit time — preview the rendered output against a
|
|
9
|
+
* sample context for UX validation.
|
|
10
|
+
*
|
|
11
|
+
* Both contexts share this surface.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The context object an expression is rendered against.
|
|
16
|
+
*
|
|
17
|
+
* Keys typically include `trigger`, `nodes`, `config`, `variables`, plus
|
|
18
|
+
* platform helpers like `now()`.
|
|
19
|
+
*
|
|
20
|
+
* Unknown is used at the value level because the shape depends on the
|
|
21
|
+
* automation's topology (which triggers / upstream artifacts are in scope).
|
|
22
|
+
*/
|
|
23
|
+
export type TemplateContext = Record<string, unknown>;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A parsed template program — opaque to consumers. Pass it to `render()`.
|
|
27
|
+
*/
|
|
28
|
+
export interface ParsedTemplate {
|
|
29
|
+
/** Discriminator so future versions can detect mismatch. */
|
|
30
|
+
readonly version: 1;
|
|
31
|
+
/** Internal AST nodes (kept as `unknown` to avoid exposing internals). */
|
|
32
|
+
readonly nodes: ReadonlyArray<unknown>;
|
|
33
|
+
/** Original source text — kept for error message context. */
|
|
34
|
+
readonly source: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A parsed condition expression — opaque to consumers. Pass it to `evaluate()`.
|
|
39
|
+
*
|
|
40
|
+
* Conditions are pure expressions (no surrounding literal text), unlike
|
|
41
|
+
* full templates which interleave text and `{{ ... }}` segments.
|
|
42
|
+
*/
|
|
43
|
+
export interface ParsedCondition {
|
|
44
|
+
readonly version: 1;
|
|
45
|
+
readonly root: import("./ast").Expr;
|
|
46
|
+
readonly source: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A position in source text. 1-indexed line and column, matching what
|
|
51
|
+
* editors expect.
|
|
52
|
+
*/
|
|
53
|
+
export interface SourcePosition {
|
|
54
|
+
line: number;
|
|
55
|
+
column: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* A source range — start and end position pair.
|
|
60
|
+
*/
|
|
61
|
+
export interface SourceRange {
|
|
62
|
+
start: SourcePosition;
|
|
63
|
+
end: SourcePosition;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Built-in filter signature.
|
|
68
|
+
*
|
|
69
|
+
* A filter is invoked as `value | name` or `value | name(arg1, arg2)`. The
|
|
70
|
+
* first argument is always the piped value; remaining are literal args
|
|
71
|
+
* from the call site.
|
|
72
|
+
*/
|
|
73
|
+
export type Filter = (value: unknown, ...args: unknown[]) => unknown;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Filter registry — name → implementation mapping.
|
|
77
|
+
*/
|
|
78
|
+
export interface FilterRegistry {
|
|
79
|
+
register(name: string, filter: Filter): void;
|
|
80
|
+
get(name: string): Filter | undefined;
|
|
81
|
+
has(name: string): boolean;
|
|
82
|
+
list(): string[];
|
|
83
|
+
}
|