@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.
@@ -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
+ }
@@ -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
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,11 @@
1
+ {
2
+ "extends": "@checkstack/tsconfig/common.json",
3
+ "include": [
4
+ "src"
5
+ ],
6
+ "references": [
7
+ {
8
+ "path": "../common"
9
+ }
10
+ ]
11
+ }