@vertesia/jst 1.3.0-dev.20260620.061059Z

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.
Files changed (44) hide show
  1. package/LICENSE +201 -0
  2. package/lib/functions/addLineNumbers.d.ts +2 -0
  3. package/lib/functions/addLineNumbers.d.ts.map +1 -0
  4. package/lib/functions/addLineNumbers.js +12 -0
  5. package/lib/functions/addLineNumbers.js.map +1 -0
  6. package/lib/functions/csv.d.ts +4 -0
  7. package/lib/functions/csv.d.ts.map +1 -0
  8. package/lib/functions/csv.js +8 -0
  9. package/lib/functions/csv.js.map +1 -0
  10. package/lib/handlebars/index.d.ts +2 -0
  11. package/lib/handlebars/index.d.ts.map +1 -0
  12. package/lib/handlebars/index.js +15 -0
  13. package/lib/handlebars/index.js.map +1 -0
  14. package/lib/index.d.ts +5 -0
  15. package/lib/index.d.ts.map +1 -0
  16. package/lib/index.js +5 -0
  17. package/lib/index.js.map +1 -0
  18. package/lib/script.d.ts +10 -0
  19. package/lib/script.d.ts.map +1 -0
  20. package/lib/script.js +19 -0
  21. package/lib/script.js.map +1 -0
  22. package/lib/template.d.ts +8 -0
  23. package/lib/template.d.ts.map +1 -0
  24. package/lib/template.js +40 -0
  25. package/lib/template.js.map +1 -0
  26. package/lib/template_old.d.ts +36 -0
  27. package/lib/template_old.d.ts.map +1 -0
  28. package/lib/template_old.js +129 -0
  29. package/lib/template_old.js.map +1 -0
  30. package/lib/validation.d.ts +50 -0
  31. package/lib/validation.d.ts.map +1 -0
  32. package/lib/validation.js +306 -0
  33. package/lib/validation.js.map +1 -0
  34. package/lib/vertesia-jst.js +2 -0
  35. package/lib/vertesia-jst.js.map +1 -0
  36. package/package.json +62 -0
  37. package/src/functions/addLineNumbers.ts +11 -0
  38. package/src/functions/csv.ts +9 -0
  39. package/src/handlebars/index.ts +15 -0
  40. package/src/index.ts +4 -0
  41. package/src/script.ts +22 -0
  42. package/src/template.ts +40 -0
  43. package/src/template_old.ts +149 -0
  44. package/src/validation.ts +355 -0
@@ -0,0 +1,149 @@
1
+ import { Script } from './script.js';
2
+
3
+ enum NodeType {
4
+ Function,
5
+ Variable,
6
+ Meta,
7
+ }
8
+
9
+ function renderFunBlock(node: FunBlock, out: string[]) {
10
+ out.push(`function ${node.signature} { return \`\\`);
11
+ out.push(`${node.lines.join('\n')}\\`);
12
+ out.push('`}');
13
+ }
14
+ function renderVarBlock(node: VarBlock, out: string[]) {
15
+ out.push(`const ${node.name} = \``);
16
+ out.push(`${node.lines.join('\n')}\\`);
17
+ out.push('`;');
18
+ }
19
+ function renderMetaNode(node: MetaNode, out: string[]) {
20
+ out.push(`__ctx.set("${node.key}", ${node.value});`);
21
+ }
22
+
23
+ export interface Node {
24
+ type: NodeType;
25
+ }
26
+
27
+ export interface BlockNode extends Node {
28
+ lines: string[];
29
+ }
30
+
31
+ export interface VarBlock extends BlockNode {
32
+ name: string;
33
+ }
34
+
35
+ export interface FunBlock extends BlockNode {
36
+ signature: string;
37
+ }
38
+
39
+ export interface MetaNode extends Node {
40
+ key: string;
41
+ value: string;
42
+ }
43
+
44
+ export class CompiledTemplate extends Script<string> {
45
+ validate() {
46
+ return super.validate({
47
+ acorn: {
48
+ allowReturnOutsideFunction: true,
49
+ locations: true,
50
+ },
51
+ });
52
+ }
53
+ }
54
+
55
+ export class Template {
56
+ _rendered: string | null = null;
57
+
58
+ constructor(public nodes: Node[]) {}
59
+
60
+ compile(globals: string[] = []) {
61
+ return new CompiledTemplate(this.render(), globals);
62
+ }
63
+
64
+ render() {
65
+ if (!this._rendered) {
66
+ this._rendered = this.renderNow();
67
+ }
68
+ return this._rendered;
69
+ }
70
+
71
+ renderNow() {
72
+ const out: string[] = [];
73
+ let foundTemplate = false;
74
+ for (const node of this.nodes) {
75
+ switch (node.type) {
76
+ case NodeType.Variable:
77
+ if (!foundTemplate && (node as VarBlock).name === 'template') {
78
+ foundTemplate = true;
79
+ }
80
+ renderVarBlock(node as VarBlock, out);
81
+ break;
82
+ case NodeType.Function:
83
+ renderFunBlock(node as FunBlock, out);
84
+ break;
85
+ case NodeType.Meta:
86
+ renderMetaNode(node as MetaNode, out);
87
+ break;
88
+ default:
89
+ throw new Error(`Unknown block type: ${node.type}`);
90
+ }
91
+ }
92
+ if (!foundTemplate) {
93
+ throw new Error('No template block found');
94
+ }
95
+ out.push('return template;');
96
+ return out.join('\n');
97
+ }
98
+
99
+ static parse(text: string) {
100
+ text = text.trim();
101
+ const lines = text.split('\n');
102
+ const linesCount = lines.length;
103
+
104
+ let block: BlockNode | null = null;
105
+ const nodes: Node[] = [];
106
+
107
+ for (let i = 0; i < linesCount; i++) {
108
+ let line = lines[i];
109
+ if (block) {
110
+ if (line.trim() === '```') {
111
+ nodes.push(block);
112
+ block = null;
113
+ } else {
114
+ block.lines.push(line);
115
+ }
116
+ } else {
117
+ // not in a block
118
+ line = line.trim();
119
+ if (!line || line.startsWith('//')) {
120
+ } else if (line.startsWith('@')) {
121
+ const m = /^@([a-z_A-Z-]+)(?:\s+(.+))?\s*$/.exec(line);
122
+ if (m) {
123
+ const meta = { type: NodeType.Meta, key: m[1], value: m[2] } as MetaNode;
124
+ nodes.push(meta);
125
+ } else {
126
+ throw new Error(`Unexpected content at line ${i + 1}: ${line}`);
127
+ }
128
+ } else if (line.startsWith('```')) {
129
+ const signature = line.substring(3).trim();
130
+ if (!signature) {
131
+ // a code block starts
132
+ throw new Error(`Trying to close a block at line ${i + 1}`);
133
+ } else if (signature.endsWith(')')) {
134
+ block = { type: NodeType.Function, signature, lines: [] } as FunBlock;
135
+ } else {
136
+ block = { type: NodeType.Variable, name: signature, lines: [] } as VarBlock;
137
+ }
138
+ } else {
139
+ throw new Error(`Unexpected content at line ${i + 1}: ${line}`);
140
+ }
141
+ }
142
+ }
143
+ if (block) {
144
+ throw new Error(`Unclosed block at line ${linesCount}`);
145
+ }
146
+
147
+ return new Template(nodes);
148
+ }
149
+ }
@@ -0,0 +1,355 @@
1
+ import {
2
+ type Function as AcornFunction,
3
+ type ArrayPattern,
4
+ type Identifier,
5
+ type MemberExpression,
6
+ type Node,
7
+ type ObjectPattern,
8
+ type Options,
9
+ type Pattern,
10
+ type Position,
11
+ parse,
12
+ type VariableDeclaration,
13
+ } from 'acorn';
14
+ import { base as baseV, type RecursiveVisitors, recursive as recursiveWalk } from 'acorn-walk';
15
+
16
+ const baseVisitor = baseV as Required<RecursiveVisitors<Scope>>;
17
+
18
+ const UnknownPosition = {
19
+ line: 0,
20
+ column: 0,
21
+ } as Position;
22
+
23
+ export class ValidationError extends Error {
24
+ constructor(
25
+ message: string,
26
+ public node: Node,
27
+ ) {
28
+ super(message);
29
+ this.name = 'ValidationError';
30
+ }
31
+
32
+ get range() {
33
+ const loc = this.node.loc;
34
+ return loc ? [loc.start, loc.end] : [UnknownPosition, UnknownPosition];
35
+ }
36
+
37
+ get start() {
38
+ return this.node.loc?.start || UnknownPosition;
39
+ }
40
+
41
+ get end() {
42
+ return this.node.loc?.end || UnknownPosition;
43
+ }
44
+
45
+ get location() {
46
+ if (this.node.loc) {
47
+ return `${this.node.loc.start.line}:${this.node.loc.start.column}`;
48
+ } else {
49
+ return `${this.node.range}`;
50
+ }
51
+ }
52
+ }
53
+
54
+ export class Scope {
55
+ readonly errors: ValidationError[];
56
+ readonly children: Scope[] = [];
57
+ readonly locals: Set<string> = new Set();
58
+ constructor(
59
+ public readonly name: string,
60
+ public readonly node: Node,
61
+ public readonly parent: Scope | null,
62
+ ) {
63
+ parent?.children.push(this);
64
+ this.errors = parent ? parent.errors : [];
65
+ (node as ScopedNode).$scope = this;
66
+ }
67
+
68
+ define(name: string) {
69
+ this.locals.add(name);
70
+ }
71
+
72
+ isDefined(name: string): boolean {
73
+ if (this.locals.has(name)) {
74
+ return true;
75
+ }
76
+ if (this.parent) {
77
+ return this.parent.isDefined(name);
78
+ }
79
+ return false;
80
+ }
81
+
82
+ pushError(message: string, node: Node) {
83
+ this.errors.push(new ValidationError(message, node));
84
+ }
85
+
86
+ hasErrors() {
87
+ return this.errors.length > 0;
88
+ }
89
+
90
+ get ok() {
91
+ return this.errors.length === 0;
92
+ }
93
+
94
+ print() {
95
+ console.log(this.name, '=>', this.locals);
96
+ for (const child of this.children) {
97
+ child.print();
98
+ }
99
+ }
100
+ }
101
+
102
+ interface ScopedNode extends Node {
103
+ $scope?: Scope;
104
+ ___safe_identifier?: boolean;
105
+ }
106
+
107
+ function addFunctionParams(node: AcornFunction, scope: Scope) {
108
+ for (const param of node.params) {
109
+ if (param.type === 'Identifier') {
110
+ scope.locals.add(param.name);
111
+ markSafeIdentifier(param);
112
+ } else if (param.type === 'AssignmentPattern') {
113
+ if (param.left.type === 'Identifier') {
114
+ scope.locals.add(param.left.name);
115
+ markSafeIdentifier(param.left);
116
+ }
117
+ } else if (param.type === 'RestElement') {
118
+ if (param.argument.type === 'Identifier') {
119
+ scope.locals.add(param.argument.name);
120
+ markSafeIdentifier(param.argument);
121
+ }
122
+ }
123
+ }
124
+ }
125
+
126
+ function markSafeIdentifier(node: Identifier) {
127
+ (node as ScopedNode).___safe_identifier = true;
128
+ }
129
+
130
+ function isSafeIdentifier(node: Node) {
131
+ return !!(node as ScopedNode).___safe_identifier;
132
+ }
133
+
134
+ export interface ValidationOptions {
135
+ globals?: string[];
136
+ allowThis?: boolean;
137
+ //allowClass: boolean; //TODO classes are not allowed for now
138
+ propsBlacklist?: string[];
139
+ acorn?: Partial<Options>;
140
+ }
141
+
142
+ const defaultAcornOpts = {
143
+ ecmaVersion: 2020,
144
+ sourceType: 'script',
145
+ } as Options;
146
+
147
+ /**
148
+ * Parse `code` and build the scope tree (one full AST walk). The returned root scope
149
+ * has `locals` populated for every nested function/block and has unsafe-construct
150
+ * errors accumulated in `root.errors`. Identifier resolution (the "Unknown identifier"
151
+ * check) is a separate second walk done by `validate` / `getFreeVariables`.
152
+ */
153
+ function buildScopeTree(code: string, opts: ValidationOptions): { root: Scope; program: Node } {
154
+ const acornOpts: Options = opts.acorn ? { ...defaultAcornOpts, ...opts.acorn } : defaultAcornOpts;
155
+ const program = parse(code, acornOpts);
156
+
157
+ const root = new Scope('#root', program, null);
158
+ if (opts.globals) {
159
+ for (const identifier of opts.globals) {
160
+ root.locals.add(identifier);
161
+ }
162
+ (program as ScopedNode).$scope = root;
163
+ }
164
+
165
+ const propsBlacklist = new Set(
166
+ opts.propsBlacklist ? opts.propsBlacklist : ['constructor', 'prototype', '__proto__'],
167
+ );
168
+
169
+ recursiveWalk(program, root, {
170
+ WithStatement(node, state, c) {
171
+ state.pushError('"with" keyword is not supported', node);
172
+ baseVisitor.WithStatement(node, state, c);
173
+ },
174
+ ForStatement(node, state, c) {
175
+ state.pushError('"for" keyword is not supported', node);
176
+ baseVisitor.ForStatement(node, state, c);
177
+ },
178
+ WhileStatement(node, state, c) {
179
+ state.pushError('"while" keyword is not supported', node);
180
+ baseVisitor.WhileStatement(node, state, c);
181
+ },
182
+ ImportExpression(node, state, c) {
183
+ state.pushError('"import" keyword is not supported', node);
184
+ baseVisitor.ImportExpression(node, state, c);
185
+ },
186
+ ImportDeclaration(node, state, c) {
187
+ state.pushError('"import" keyword is not supported', node);
188
+ baseVisitor.ImportDeclaration(node, state, c);
189
+ },
190
+ Class(node, state, c) {
191
+ state.pushError('Classes are not supported', node);
192
+ baseVisitor.Class(node, state, c);
193
+ },
194
+ ThisExpression(node, state, c) {
195
+ if (!opts.allowThis) {
196
+ state.pushError('"this" keyword is not supported', node);
197
+ }
198
+ baseVisitor.ThisExpression(node, state, c);
199
+ },
200
+ ObjectPattern(node, state, c) {
201
+ for (const prop of (node as unknown as ObjectPattern).properties) {
202
+ if (prop.type === 'Property') {
203
+ if (prop.key.type === 'Identifier') {
204
+ markSafeIdentifier(prop.key);
205
+ if (prop.kind === 'init' && propsBlacklist.has(prop.key.name)) {
206
+ state.pushError(`Property "${prop.key.name}" is not allowed`, prop.key);
207
+ }
208
+ }
209
+ } else if (prop.type === 'RestElement') {
210
+ if (prop.argument.type === 'Identifier') {
211
+ markSafeIdentifier(prop.argument);
212
+ }
213
+ }
214
+ }
215
+ baseVisitor.ObjectPattern(node, state, c);
216
+ },
217
+ ArrayPattern(node, state, c) {
218
+ for (const prop of (node as unknown as ArrayPattern).elements) {
219
+ if (prop) {
220
+ if (prop.type === 'Identifier') {
221
+ markSafeIdentifier(prop);
222
+ //TODO is this really needed?
223
+ // if (propBlacklist.has(prop.name)) {
224
+ // state.pushError(`Property "${prop.name}" is not allowed`, prop);
225
+ // }
226
+ } else if (prop.type === 'RestElement') {
227
+ if (prop.argument.type === 'Identifier') {
228
+ markSafeIdentifier(prop.argument);
229
+ }
230
+ }
231
+ }
232
+ }
233
+ baseVisitor.ArrayPattern(node, state, c);
234
+ },
235
+ MemberExpression(node, state, c) {
236
+ const prop = (node as unknown as MemberExpression).property;
237
+ if (prop.type === 'Identifier') {
238
+ markSafeIdentifier(prop);
239
+ if (propsBlacklist.has(prop.name)) {
240
+ state.pushError(`Property "${prop.name}" is not allowed`, prop);
241
+ }
242
+ } else if (prop.type === 'Literal') {
243
+ if (propsBlacklist.has(String(prop.value))) {
244
+ state.pushError(`Property "${prop.value}" is not allowed`, prop);
245
+ }
246
+ } else {
247
+ state.pushError('Dynamic property lookup is not supported', prop);
248
+ }
249
+ baseVisitor.MemberExpression(node, state, c);
250
+ },
251
+ Function(node: AcornFunction, state, c) {
252
+ const name = node.id?.name;
253
+ if (name) {
254
+ // arrow functions have no name
255
+ state.define(name);
256
+ }
257
+ const scope = new Scope(name || '#anonymous', node, state);
258
+ addFunctionParams(node, scope);
259
+ baseVisitor.Function(node, scope, c);
260
+ },
261
+ VariableDeclaration(node, state, c) {
262
+ for (const decl of (node as unknown as VariableDeclaration).declarations) {
263
+ if (decl.id.type === 'Identifier') {
264
+ markSafeIdentifier(decl.id);
265
+ state.define(decl.id.name);
266
+ } else if (decl.id.type === 'ObjectPattern') {
267
+ //the ObjectPattern rule will call markSafeIdentifier
268
+ for (const prop of decl.id.properties) {
269
+ if (prop.type === 'Property') {
270
+ if (prop.value.type === 'Identifier') state.define(prop.value.name);
271
+ } else if (prop.type === 'RestElement') {
272
+ if (prop.argument.type === 'Identifier') state.define(prop.argument.name);
273
+ }
274
+ }
275
+ } else if (decl.id.type === 'ArrayPattern') {
276
+ //the ArrayPattern rule will call markSafeIdentifier
277
+ for (const prop of decl.id.elements as Pattern[]) {
278
+ if (prop.type === 'Identifier') {
279
+ state.define(prop.name);
280
+ } else if (prop.type === 'RestElement') {
281
+ if (prop.argument.type === 'Identifier') state.define(prop.argument.name);
282
+ }
283
+ }
284
+ }
285
+ }
286
+ baseVisitor.VariableDeclaration(node, state, c);
287
+ },
288
+ } as RecursiveVisitors<Scope>);
289
+
290
+ return { root, program };
291
+ }
292
+
293
+ export function validate(code: string, opts: ValidationOptions = {}) {
294
+ const { root, program } = buildScopeTree(code, opts);
295
+
296
+ recursiveWalk(program, root, {
297
+ Function(node: AcornFunction, state, c) {
298
+ baseVisitor.Function(node, (node as ScopedNode).$scope || state, c);
299
+ },
300
+ Identifier(node, state, c) {
301
+ const identifier = node as unknown as Identifier;
302
+ if (!isSafeIdentifier(node) && !state.isDefined(identifier.name)) {
303
+ state.pushError(`Unknown identifier "${identifier.name}"`, node);
304
+ }
305
+ baseVisitor.Identifier(node, state, c);
306
+ },
307
+ } as RecursiveVisitors<Scope>);
308
+
309
+ return root;
310
+ }
311
+
312
+ export interface FreeVariablesResult {
313
+ /** Identifiers referenced in `code` that are not bound by any enclosing scope and not in `opts.globals`. */
314
+ vars: Set<string>;
315
+ /** Errors for unsafe constructs (with/for/while/import/class/this/dynamic property access/blacklisted props). Does not include identifier-resolution errors. */
316
+ errors: ValidationError[];
317
+ }
318
+
319
+ /**
320
+ * Walk `code` and return every identifier that is referenced but not bound by any
321
+ * enclosing function/variable declaration and not listed in `opts.globals`. Pass
322
+ * known runtime-injected names (e.g. JST's `_`, `Array`, `Set`) in `opts.globals`
323
+ * so they don't appear as free vars. Also surfaces unsafe-construct errors from
324
+ * the same parse pass.
325
+ */
326
+ export function getFreeVariables(code: string, opts: ValidationOptions = {}): FreeVariablesResult {
327
+ const { root, program } = buildScopeTree(code, opts);
328
+
329
+ const vars = new Set<string>();
330
+ recursiveWalk(program, root, {
331
+ Function(node: AcornFunction, state, c) {
332
+ baseVisitor.Function(node, (node as ScopedNode).$scope || state, c);
333
+ },
334
+ Identifier(node, state, c) {
335
+ const identifier = node as unknown as Identifier;
336
+ if (!isSafeIdentifier(node) && !state.isDefined(identifier.name)) {
337
+ vars.add(identifier.name);
338
+ }
339
+ baseVisitor.Identifier(node, state, c);
340
+ },
341
+ } as RecursiveVisitors<Scope>);
342
+
343
+ return { vars, errors: root.errors };
344
+ }
345
+
346
+ export class CompositeError extends Error {
347
+ constructor(
348
+ public errors: Error[],
349
+ message?: string,
350
+ ) {
351
+ super(`${message || 'Composite Error'}\n${errors.map((err) => err.message).join('\n* ')}`);
352
+ this.errors = errors;
353
+ this.name = 'CompositeError';
354
+ }
355
+ }