@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/filters.ts
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import type { Filter, FilterRegistry } from "./types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Create an empty filter registry.
|
|
5
|
+
*/
|
|
6
|
+
export function createFilterRegistry(): FilterRegistry {
|
|
7
|
+
const filters = new Map<string, Filter>();
|
|
8
|
+
return {
|
|
9
|
+
register(name, filter) {
|
|
10
|
+
filters.set(name, filter);
|
|
11
|
+
},
|
|
12
|
+
get(name) {
|
|
13
|
+
return filters.get(name);
|
|
14
|
+
},
|
|
15
|
+
has(name) {
|
|
16
|
+
return filters.has(name);
|
|
17
|
+
},
|
|
18
|
+
list() {
|
|
19
|
+
return [...filters.keys()].toSorted();
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Create a registry pre-loaded with the default filter set.
|
|
26
|
+
*
|
|
27
|
+
* Filters intentionally do NOT throw on null/undefined — they pass it
|
|
28
|
+
* through. Operators can chain `| default("...")` to substitute a value.
|
|
29
|
+
*/
|
|
30
|
+
export function createDefaultFilterRegistry(): FilterRegistry {
|
|
31
|
+
const registry = createFilterRegistry();
|
|
32
|
+
registry.register("default", filterDefault);
|
|
33
|
+
registry.register("upper", filterUpper);
|
|
34
|
+
registry.register("lower", filterLower);
|
|
35
|
+
registry.register("capitalize", filterCapitalize);
|
|
36
|
+
registry.register("trim", filterTrim);
|
|
37
|
+
registry.register("truncate", filterTruncate);
|
|
38
|
+
registry.register("length", filterLength);
|
|
39
|
+
registry.register("json", filterJson);
|
|
40
|
+
registry.register("iso", filterIso);
|
|
41
|
+
registry.register("date", filterDate);
|
|
42
|
+
registry.register("join", filterJoin);
|
|
43
|
+
registry.register("replace", filterReplace);
|
|
44
|
+
registry.register("not", filterNot);
|
|
45
|
+
return registry;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ─── Built-ins ─────────────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
function filterDefault(value: unknown, fallback: unknown): unknown {
|
|
51
|
+
if (value === null || value === undefined || value === "") return fallback;
|
|
52
|
+
return value;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function filterUpper(value: unknown): unknown {
|
|
56
|
+
return typeof value === "string" ? value.toUpperCase() : value;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function filterLower(value: unknown): unknown {
|
|
60
|
+
return typeof value === "string" ? value.toLowerCase() : value;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function filterCapitalize(value: unknown): unknown {
|
|
64
|
+
if (typeof value !== "string" || value.length === 0) return value;
|
|
65
|
+
return value[0]!.toUpperCase() + value.slice(1);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function filterTrim(value: unknown): unknown {
|
|
69
|
+
return typeof value === "string" ? value.trim() : value;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function filterTruncate(value: unknown, length: unknown): unknown {
|
|
73
|
+
if (typeof value !== "string") return value;
|
|
74
|
+
const n = typeof length === "number" ? length : Number(length);
|
|
75
|
+
if (!Number.isFinite(n) || n <= 0) return value;
|
|
76
|
+
if (value.length <= n) return value;
|
|
77
|
+
return value.slice(0, n) + "…";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function filterLength(value: unknown): unknown {
|
|
81
|
+
if (typeof value === "string" || Array.isArray(value)) return value.length;
|
|
82
|
+
if (value && typeof value === "object") return Object.keys(value).length;
|
|
83
|
+
return 0;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function filterJson(value: unknown): unknown {
|
|
87
|
+
return JSON.stringify(value);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function filterIso(value: unknown): unknown {
|
|
91
|
+
if (value instanceof Date) return value.toISOString();
|
|
92
|
+
if (typeof value === "string" || typeof value === "number") {
|
|
93
|
+
const d = new Date(value);
|
|
94
|
+
return Number.isNaN(d.getTime()) ? value : d.toISOString();
|
|
95
|
+
}
|
|
96
|
+
return value;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function filterDate(value: unknown, format: unknown): unknown {
|
|
100
|
+
const fmt = typeof format === "string" ? format : "ISO";
|
|
101
|
+
let date: Date | undefined;
|
|
102
|
+
if (value instanceof Date) date = value;
|
|
103
|
+
else if (typeof value === "string" || typeof value === "number") {
|
|
104
|
+
const d = new Date(value);
|
|
105
|
+
if (!Number.isNaN(d.getTime())) date = d;
|
|
106
|
+
}
|
|
107
|
+
if (!date) return value;
|
|
108
|
+
if (fmt === "ISO") return date.toISOString();
|
|
109
|
+
if (fmt === "date") return date.toISOString().slice(0, 10);
|
|
110
|
+
if (fmt === "time") return date.toISOString().slice(11, 19);
|
|
111
|
+
if (fmt === "unix") return Math.floor(date.getTime() / 1000);
|
|
112
|
+
if (fmt === "rfc2822") return date.toUTCString();
|
|
113
|
+
return date.toISOString();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function filterJoin(value: unknown, separator: unknown): unknown {
|
|
117
|
+
if (!Array.isArray(value)) return value;
|
|
118
|
+
const sep = typeof separator === "string" ? separator : ", ";
|
|
119
|
+
return value.map(String).join(sep);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function filterReplace(value: unknown, search: unknown, replacement: unknown): unknown {
|
|
123
|
+
if (typeof value !== "string") return value;
|
|
124
|
+
if (typeof search !== "string") return value;
|
|
125
|
+
const repl = typeof replacement === "string" ? replacement : "";
|
|
126
|
+
return value.split(search).join(repl);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function filterNot(value: unknown): unknown {
|
|
130
|
+
return !isTruthy(value);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Centralised truthiness rule. Mirrors JavaScript's notion plus: empty
|
|
135
|
+
* string is falsy, empty arrays are falsy, empty plain objects are falsy.
|
|
136
|
+
* Exported so the renderer can share the same rule with filters.
|
|
137
|
+
*/
|
|
138
|
+
export function isTruthy(value: unknown): boolean {
|
|
139
|
+
if (value === null || value === undefined) return false;
|
|
140
|
+
if (value === false || value === 0) return false;
|
|
141
|
+
if (typeof value === "string") return value.length > 0;
|
|
142
|
+
if (Array.isArray(value)) return value.length > 0;
|
|
143
|
+
if (typeof value === "object") return Object.keys(value).length > 0;
|
|
144
|
+
return Boolean(value);
|
|
145
|
+
}
|
package/src/index.ts
ADDED
package/src/parser.ts
ADDED
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Expr,
|
|
3
|
+
TemplateNode,
|
|
4
|
+
} from "./ast";
|
|
5
|
+
import { TemplateParseError, pointRange } from "./errors";
|
|
6
|
+
import { Tokenizer, type Token, type TokenKind } from "./tokenizer";
|
|
7
|
+
import type { ParsedCondition, ParsedTemplate, SourceRange } from "./types";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Parse a template string — literal text interleaved with `{{ expr }}`
|
|
11
|
+
* blocks — into a `ParsedTemplate` that can be rendered.
|
|
12
|
+
*/
|
|
13
|
+
export function parseTemplate(source: string): ParsedTemplate {
|
|
14
|
+
const tokens = new Tokenizer(source).tokenize();
|
|
15
|
+
const parser = new Parser(tokens, source);
|
|
16
|
+
const nodes = parser.parseTemplateNodes();
|
|
17
|
+
return { version: 1, nodes, source };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Parse a single expression with no surrounding text. Used for conditions
|
|
22
|
+
* (`when: "..."` fields, `condition` action guards, trigger filters).
|
|
23
|
+
*
|
|
24
|
+
* The input is the raw expression source — the parser does NOT expect or
|
|
25
|
+
* accept surrounding `{{ }}`.
|
|
26
|
+
*/
|
|
27
|
+
export function parseCondition(source: string): ParsedCondition {
|
|
28
|
+
// Wrap source so the tokenizer enters expression mode.
|
|
29
|
+
const wrapped = `{{${source}}}`;
|
|
30
|
+
const tokens = new Tokenizer(wrapped).tokenize();
|
|
31
|
+
const parser = new Parser(tokens, wrapped);
|
|
32
|
+
// Skip the synthesized EXPR_OPEN.
|
|
33
|
+
parser.expect("EXPR_OPEN");
|
|
34
|
+
const expr = parser.parseExpression();
|
|
35
|
+
parser.expect("EXPR_CLOSE");
|
|
36
|
+
return { version: 1, root: expr, source };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
class Parser {
|
|
40
|
+
private pos = 0;
|
|
41
|
+
|
|
42
|
+
constructor(
|
|
43
|
+
private readonly tokens: Token[],
|
|
44
|
+
private readonly source: string,
|
|
45
|
+
) {}
|
|
46
|
+
|
|
47
|
+
parseTemplateNodes(): TemplateNode[] {
|
|
48
|
+
const nodes: TemplateNode[] = [];
|
|
49
|
+
while (!this.eof()) {
|
|
50
|
+
const t = this.peek();
|
|
51
|
+
if (t.kind === "LITERAL") {
|
|
52
|
+
this.advance();
|
|
53
|
+
nodes.push({ kind: "text", value: t.value, range: t.range });
|
|
54
|
+
} else if (t.kind === "EXPR_OPEN") {
|
|
55
|
+
const start = t.range.start;
|
|
56
|
+
this.advance();
|
|
57
|
+
const expr = this.parseExpression();
|
|
58
|
+
const close = this.expect("EXPR_CLOSE");
|
|
59
|
+
nodes.push({
|
|
60
|
+
kind: "expression",
|
|
61
|
+
expression: expr,
|
|
62
|
+
range: { start, end: close.range.end },
|
|
63
|
+
});
|
|
64
|
+
} else {
|
|
65
|
+
throw new TemplateParseError({
|
|
66
|
+
message: `Unexpected token ${t.kind} at top level`,
|
|
67
|
+
source: this.source,
|
|
68
|
+
range: t.range,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return nodes;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Parse an expression with full operator precedence.
|
|
77
|
+
*
|
|
78
|
+
* Grammar (lowest precedence first):
|
|
79
|
+
*
|
|
80
|
+
* expression := ternary
|
|
81
|
+
* ternary := pipe ('?' expression ':' expression)?
|
|
82
|
+
* pipe := or ('|' IDENT ('(' args ')')?)*
|
|
83
|
+
* or := and ('||' and)*
|
|
84
|
+
* and := eq ('&&' eq)*
|
|
85
|
+
* eq := cmp (('==' | '!=') cmp)*
|
|
86
|
+
* cmp := unary (('<' | '>' | '<=' | '>=') unary)*
|
|
87
|
+
* unary := '!' unary | primary
|
|
88
|
+
* primary := literal | identifier (member | index)* | '(' expression ')'
|
|
89
|
+
*/
|
|
90
|
+
parseExpression(): Expr {
|
|
91
|
+
return this.parseTernary();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
private parseTernary(): Expr {
|
|
95
|
+
const cond = this.parsePipe();
|
|
96
|
+
if (this.match("QUESTION")) {
|
|
97
|
+
const consequent = this.parseExpression();
|
|
98
|
+
this.expect("COLON");
|
|
99
|
+
const alternate = this.parseExpression();
|
|
100
|
+
return {
|
|
101
|
+
kind: "ternary",
|
|
102
|
+
condition: cond,
|
|
103
|
+
consequent,
|
|
104
|
+
alternate,
|
|
105
|
+
range: this.span(cond.range, alternate.range),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
return cond;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
private parsePipe(): Expr {
|
|
112
|
+
let value = this.parseOr();
|
|
113
|
+
while (this.match("PIPE")) {
|
|
114
|
+
const filterTok = this.expect("IDENT");
|
|
115
|
+
const args: Expr[] = [];
|
|
116
|
+
if (this.match("LPAREN")) {
|
|
117
|
+
if (!this.check("RPAREN")) {
|
|
118
|
+
args.push(this.parseExpression());
|
|
119
|
+
while (this.match("COMMA")) {
|
|
120
|
+
args.push(this.parseExpression());
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
this.expect("RPAREN");
|
|
124
|
+
}
|
|
125
|
+
value = {
|
|
126
|
+
kind: "pipe",
|
|
127
|
+
value,
|
|
128
|
+
filter: filterTok.value,
|
|
129
|
+
args,
|
|
130
|
+
range: this.span(
|
|
131
|
+
value.range,
|
|
132
|
+
args.length > 0 ? args.at(-1)!.range : filterTok.range,
|
|
133
|
+
),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
return value;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private parseOr(): Expr {
|
|
140
|
+
let left = this.parseAnd();
|
|
141
|
+
while (this.check("OR")) {
|
|
142
|
+
const opTok = this.advance();
|
|
143
|
+
const right = this.parseAnd();
|
|
144
|
+
left = {
|
|
145
|
+
kind: "binary",
|
|
146
|
+
op: "||",
|
|
147
|
+
left,
|
|
148
|
+
right,
|
|
149
|
+
range: this.span(left.range, right.range),
|
|
150
|
+
};
|
|
151
|
+
void opTok;
|
|
152
|
+
}
|
|
153
|
+
return left;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
private parseAnd(): Expr {
|
|
157
|
+
let left = this.parseEquality();
|
|
158
|
+
while (this.check("AND")) {
|
|
159
|
+
this.advance();
|
|
160
|
+
const right = this.parseEquality();
|
|
161
|
+
left = {
|
|
162
|
+
kind: "binary",
|
|
163
|
+
op: "&&",
|
|
164
|
+
left,
|
|
165
|
+
right,
|
|
166
|
+
range: this.span(left.range, right.range),
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
return left;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
private parseEquality(): Expr {
|
|
173
|
+
let left = this.parseComparison();
|
|
174
|
+
while (this.check("EQ") || this.check("NEQ")) {
|
|
175
|
+
const opTok = this.advance();
|
|
176
|
+
const right = this.parseComparison();
|
|
177
|
+
left = {
|
|
178
|
+
kind: "binary",
|
|
179
|
+
op: opTok.kind === "EQ" ? "==" : "!=",
|
|
180
|
+
left,
|
|
181
|
+
right,
|
|
182
|
+
range: this.span(left.range, right.range),
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
return left;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
private parseComparison(): Expr {
|
|
189
|
+
let left = this.parseUnary();
|
|
190
|
+
while (
|
|
191
|
+
this.check("LT") ||
|
|
192
|
+
this.check("GT") ||
|
|
193
|
+
this.check("LTE") ||
|
|
194
|
+
this.check("GTE")
|
|
195
|
+
) {
|
|
196
|
+
const opTok = this.advance();
|
|
197
|
+
const right = this.parseUnary();
|
|
198
|
+
const op =
|
|
199
|
+
opTok.kind === "LT"
|
|
200
|
+
? "<"
|
|
201
|
+
: opTok.kind === "GT"
|
|
202
|
+
? ">"
|
|
203
|
+
: opTok.kind === "LTE"
|
|
204
|
+
? "<="
|
|
205
|
+
: ">=";
|
|
206
|
+
left = {
|
|
207
|
+
kind: "binary",
|
|
208
|
+
op,
|
|
209
|
+
left,
|
|
210
|
+
right,
|
|
211
|
+
range: this.span(left.range, right.range),
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
return left;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
private parseUnary(): Expr {
|
|
218
|
+
if (this.check("NOT")) {
|
|
219
|
+
const opTok = this.advance();
|
|
220
|
+
const operand = this.parseUnary();
|
|
221
|
+
return {
|
|
222
|
+
kind: "unary",
|
|
223
|
+
op: "!",
|
|
224
|
+
operand,
|
|
225
|
+
range: this.span(opTok.range, operand.range),
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
return this.parsePostfix();
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
private parsePostfix(): Expr {
|
|
232
|
+
let expr = this.parsePrimary();
|
|
233
|
+
// Member / index access chain
|
|
234
|
+
while (this.check("DOT") || this.check("LBRACKET")) {
|
|
235
|
+
if (this.match("DOT")) {
|
|
236
|
+
const propTok = this.expect("IDENT");
|
|
237
|
+
expr = {
|
|
238
|
+
kind: "member",
|
|
239
|
+
object: expr,
|
|
240
|
+
property: propTok.value,
|
|
241
|
+
range: this.span(expr.range, propTok.range),
|
|
242
|
+
};
|
|
243
|
+
} else {
|
|
244
|
+
this.expect("LBRACKET");
|
|
245
|
+
const indexExpr = this.parseExpression();
|
|
246
|
+
const closeTok = this.expect("RBRACKET");
|
|
247
|
+
expr = {
|
|
248
|
+
kind: "index",
|
|
249
|
+
object: expr,
|
|
250
|
+
index: indexExpr,
|
|
251
|
+
range: this.span(expr.range, closeTok.range),
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return expr;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
private parsePrimary(): Expr {
|
|
259
|
+
const t = this.peek();
|
|
260
|
+
if (t.kind === "NUMBER") {
|
|
261
|
+
this.advance();
|
|
262
|
+
return { kind: "literal", value: Number(t.value), range: t.range };
|
|
263
|
+
}
|
|
264
|
+
if (t.kind === "STRING") {
|
|
265
|
+
this.advance();
|
|
266
|
+
return { kind: "literal", value: t.value, range: t.range };
|
|
267
|
+
}
|
|
268
|
+
if (t.kind === "TRUE") {
|
|
269
|
+
this.advance();
|
|
270
|
+
return { kind: "literal", value: true, range: t.range };
|
|
271
|
+
}
|
|
272
|
+
if (t.kind === "FALSE") {
|
|
273
|
+
this.advance();
|
|
274
|
+
return { kind: "literal", value: false, range: t.range };
|
|
275
|
+
}
|
|
276
|
+
if (t.kind === "NULL") {
|
|
277
|
+
this.advance();
|
|
278
|
+
return { kind: "literal", value: null, range: t.range };
|
|
279
|
+
}
|
|
280
|
+
if (t.kind === "IDENT") {
|
|
281
|
+
this.advance();
|
|
282
|
+
return { kind: "identifier", name: t.value, range: t.range };
|
|
283
|
+
}
|
|
284
|
+
if (t.kind === "LPAREN") {
|
|
285
|
+
this.advance();
|
|
286
|
+
const inner = this.parseExpression();
|
|
287
|
+
this.expect("RPAREN");
|
|
288
|
+
return inner;
|
|
289
|
+
}
|
|
290
|
+
throw new TemplateParseError({
|
|
291
|
+
message: `Unexpected token ${t.kind} (${t.value || "EOF"}) in expression`,
|
|
292
|
+
source: this.source,
|
|
293
|
+
range: t.range.start.line === 0 ? pointRange(t.range.start) : t.range,
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ─── Token utilities ─────────────────────────────────────────────────────
|
|
298
|
+
|
|
299
|
+
private peek(): Token {
|
|
300
|
+
return this.tokens[this.pos]!;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
private advance(): Token {
|
|
304
|
+
const t = this.tokens[this.pos]!;
|
|
305
|
+
this.pos += 1;
|
|
306
|
+
return t;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
private check(kind: TokenKind): boolean {
|
|
310
|
+
return this.peek().kind === kind;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
private match(kind: TokenKind): boolean {
|
|
314
|
+
if (this.check(kind)) {
|
|
315
|
+
this.advance();
|
|
316
|
+
return true;
|
|
317
|
+
}
|
|
318
|
+
return false;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
expect(kind: TokenKind): Token {
|
|
322
|
+
const t = this.peek();
|
|
323
|
+
if (t.kind !== kind) {
|
|
324
|
+
throw new TemplateParseError({
|
|
325
|
+
message: `Expected ${kind} but found ${t.kind}`,
|
|
326
|
+
source: this.source,
|
|
327
|
+
range: t.range,
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
return this.advance();
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
private eof(): boolean {
|
|
334
|
+
return this.peek().kind === "EOF";
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
private span(a: SourceRange, b: SourceRange): SourceRange {
|
|
338
|
+
return { start: a.start, end: b.end };
|
|
339
|
+
}
|
|
340
|
+
}
|