@tsomaiatech/moxite 1.0.0 → 1.0.2
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/README.md +12 -12
- package/dist/__tests__/template-engine.test.d.ts +1 -0
- package/dist/__tests__/template-engine.test.js +389 -0
- package/dist/template-engine.d.ts +80 -0
- package/dist/template-engine.js +500 -0
- package/dist/template-manager.d.ts +21 -0
- package/dist/template-manager.js +100 -0
- package/package.json +6 -2
- package/idea-plugin/build.gradle.kts +0 -60
- package/idea-plugin/gradle.properties +0 -14
- package/idea-plugin/settings.gradle.kts +0 -1
- package/idea-plugin/src/main/flex/Moxite.flex +0 -101
- package/idea-plugin/src/main/grammar/Moxite.bnf +0 -79
- package/idea-plugin/src/main/java/tech/tsomaia/moxite/idea/MoxiteFileType.java +0 -39
- package/idea-plugin/src/main/java/tech/tsomaia/moxite/idea/MoxiteIcons.java +0 -9
- package/idea-plugin/src/main/java/tech/tsomaia/moxite/idea/MoxiteLanguage.java +0 -11
- package/idea-plugin/src/main/java/tech/tsomaia/moxite/idea/MoxiteParserDefinition.java +0 -77
- package/idea-plugin/src/main/java/tech/tsomaia/moxite/idea/MoxiteSyntaxHighlighter.java +0 -82
- package/idea-plugin/src/main/java/tech/tsomaia/moxite/idea/MoxiteSyntaxHighlighterFactory.java +0 -16
- package/idea-plugin/src/main/java/tech/tsomaia/moxite/idea/lexer/MoxiteLexerAdapter.java +0 -9
- package/idea-plugin/src/main/java/tech/tsomaia/moxite/idea/parser/MoxiteParserUtil.java +0 -6
- package/idea-plugin/src/main/java/tech/tsomaia/moxite/idea/psi/MoxiteElementType.java +0 -12
- package/idea-plugin/src/main/java/tech/tsomaia/moxite/idea/psi/MoxiteFile.java +0 -25
- package/idea-plugin/src/main/java/tech/tsomaia/moxite/idea/psi/MoxiteTokenType.java +0 -17
- package/idea-plugin/src/main/resources/META-INF/plugin.xml +0 -32
- package/src/__tests__/template-engine.test.ts +0 -437
- package/src/template-engine.ts +0 -480
- package/src/template-manager.ts +0 -75
package/README.md
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
# Moxite Template Engine
|
|
2
2
|
|
|
3
|
-
Moxite is
|
|
3
|
+
Moxite is a fast, structural, and strictly evaluated template engine designed to enforce a clear separation between application logic and the presentation layer.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Moxite approaches template parsing through a dedicated, character-by-character Lexer and a recursive descent Abstract Syntax Tree (AST) Parser, moving away from string-replacement or Regex-heavy architectures.
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
By evaluating an isolated AST, Moxite cleanly scopes template logic and ensures high reliability when parsing complex, overlapping data structures.
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
## Design Philosophy
|
|
10
10
|
|
|
11
|
-
Moxite
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
11
|
+
The core philosophy of Moxite is to keep templates simple and focused purely on rendering:
|
|
12
|
+
|
|
13
|
+
1. **Explicit Lexing**: Every template token is mapped through an atomic Lexical State machine and evaluated via a recursive descent Parser, ensuring predictable boundaries even when templates overlap with other syntaxes like JSON, bash scripts, or nested code blocks.
|
|
14
|
+
2. **Strict Expression Evaluation**: The expression parser deliberately limits complex inline operations. Features like inline array definitions `[1, 2]` or JSON closures `{key: val}` are intentionally unsupported, encouraging developers to construct their data payloads cleanly in the upstream application code.
|
|
15
|
+
3. **Implicit Safe Navigation**: The engine automatically protects against `null` or `undefined` property access without throwing verbose errors. For example, `{{ user.profile.name }}` is implicitly handled as `user?.profile?.name` under the hood.
|
|
16
16
|
|
|
17
17
|
## Syntax Outline
|
|
18
18
|
|
|
@@ -40,7 +40,7 @@ Implicit Safe Output: {{ user.deep.missing.path.shouldNotCrash }}
|
|
|
40
40
|
@endfor
|
|
41
41
|
```
|
|
42
42
|
|
|
43
|
-
###
|
|
43
|
+
### Block Scoping (`@const`)
|
|
44
44
|
```mx
|
|
45
45
|
@const greeting = "Hello"
|
|
46
46
|
@const role = user.role
|
|
@@ -49,7 +49,7 @@ Implicit Safe Output: {{ user.deep.missing.path.shouldNotCrash }}
|
|
|
49
49
|
```
|
|
50
50
|
|
|
51
51
|
### Pipes (`|`)
|
|
52
|
-
Pipes allow you to pass
|
|
52
|
+
Pipes allow you to pass variables through formatting functions registered in your evaluation context.
|
|
53
53
|
```mx
|
|
54
54
|
{{ user.name | upper }}
|
|
55
55
|
{{ total | currency: "USD" }}
|
|
@@ -58,4 +58,4 @@ Pipes allow you to pass values through pure formatting functions registered in t
|
|
|
58
58
|
## Structure
|
|
59
59
|
|
|
60
60
|
- `src/`: Contains the pure TypeScript reference implementation of the strict Lexer, AST Parser, and Evaluator (`template-engine.ts`).
|
|
61
|
-
- `idea-plugin/`:
|
|
61
|
+
- `idea-plugin/`: A Gradle project containing the IntelliJ IDEA plugin. Powered by Grammar-Kit and JFlex, this plugin provides native JetBrains syntax highlighting, completion, and semantic validation for `.mx` files using the exact same DFA token boundaries as the TypeScript engine.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const template_engine_1 = require("../template-engine");
|
|
4
|
+
describe('Template Engine', () => {
|
|
5
|
+
it('renders text only', () => {
|
|
6
|
+
const tpl = 'Hello world';
|
|
7
|
+
expect((0, template_engine_1.render)(tpl, {})).toBe('Hello world');
|
|
8
|
+
});
|
|
9
|
+
it('handles interpolation & implicit safe chaining', () => {
|
|
10
|
+
const tpl = 'Hello {{ user.profile.name }}!';
|
|
11
|
+
const ctx = { user: { profile: { name: 'Alice' } } };
|
|
12
|
+
expect((0, template_engine_1.render)(tpl, ctx)).toBe('Hello Alice!');
|
|
13
|
+
// Safe chaining
|
|
14
|
+
const ctxBad = {};
|
|
15
|
+
expect((0, template_engine_1.render)(tpl, ctxBad)).toBe('Hello !');
|
|
16
|
+
});
|
|
17
|
+
it('applies pipes correctly', () => {
|
|
18
|
+
const tpl = 'Hello {{ name | upper }} - {{ num | add: "5" }}';
|
|
19
|
+
const ctx = { name: 'bob', num: 10 };
|
|
20
|
+
const pipes = {
|
|
21
|
+
upper: (val) => val.toUpperCase(),
|
|
22
|
+
add: (val, amt) => val + Number(amt)
|
|
23
|
+
};
|
|
24
|
+
expect((0, template_engine_1.render)(tpl, ctx, pipes)).toBe('Hello BOB - 15');
|
|
25
|
+
});
|
|
26
|
+
it('evaluates @if / @else if / @else', () => {
|
|
27
|
+
const tpl = `
|
|
28
|
+
@if (user.isAdmin)
|
|
29
|
+
Admin
|
|
30
|
+
@else if (user.isGuest === "yes")
|
|
31
|
+
Guest
|
|
32
|
+
@else if (user.age > 18)
|
|
33
|
+
Adult
|
|
34
|
+
@else
|
|
35
|
+
User
|
|
36
|
+
@endif
|
|
37
|
+
`.trim();
|
|
38
|
+
expect((0, template_engine_1.render)(tpl, { user: { isAdmin: true } }).trim()).toBe('Admin');
|
|
39
|
+
expect((0, template_engine_1.render)(tpl, { user: { isGuest: "yes" } }).trim()).toBe('Guest');
|
|
40
|
+
expect((0, template_engine_1.render)(tpl, { user: { age: 20 } }).trim()).toBe('Adult');
|
|
41
|
+
expect((0, template_engine_1.render)(tpl, { user: { age: 10 } }).trim()).toBe('User');
|
|
42
|
+
});
|
|
43
|
+
it('runs @for loops', () => {
|
|
44
|
+
const tpl = `
|
|
45
|
+
@for (item of items)
|
|
46
|
+
- {{ item.name }}
|
|
47
|
+
@endfor
|
|
48
|
+
`.trim();
|
|
49
|
+
const ctx = { items: [{ name: 'A' }, { name: 'B' }] };
|
|
50
|
+
expect((0, template_engine_1.render)(tpl, ctx).trim()).toBe('- A\n- B');
|
|
51
|
+
});
|
|
52
|
+
it('supports @const block-level scoping', () => {
|
|
53
|
+
const tpl = `
|
|
54
|
+
@const a = 1
|
|
55
|
+
@if (true)
|
|
56
|
+
@const b = 2
|
|
57
|
+
{{ a }} - {{ b }}
|
|
58
|
+
@endif
|
|
59
|
+
@if (true)
|
|
60
|
+
@const b = 3
|
|
61
|
+
{{ a }} - {{ b }}
|
|
62
|
+
@endif
|
|
63
|
+
`.trim();
|
|
64
|
+
const ctx = {};
|
|
65
|
+
expect((0, template_engine_1.render)(tpl, ctx).trim()).toBe('1 - 2\n1 - 3');
|
|
66
|
+
});
|
|
67
|
+
it('executes complex battle test (Nested @for, @if, @const, Pipes)', () => {
|
|
68
|
+
const tpl = `
|
|
69
|
+
@const roleName = user.role
|
|
70
|
+
@if (user.isActive)
|
|
71
|
+
@for (project of user.projects)
|
|
72
|
+
@const projectStatus = project.status
|
|
73
|
+
@if (projectStatus === "active")
|
|
74
|
+
Active Project [{{ roleName | upper | prefix: ">> " }}]: {{ project.name }}
|
|
75
|
+
@for (task of project.tasks)
|
|
76
|
+
- {{ task.title }} ({{ task.priority | upper }})
|
|
77
|
+
@endfor
|
|
78
|
+
@else if (projectStatus === "archived")
|
|
79
|
+
Archived Project [{{ roleName | upper }}]: {{ project.name }}
|
|
80
|
+
@else
|
|
81
|
+
Unknown Project Status
|
|
82
|
+
@endif
|
|
83
|
+
@endfor
|
|
84
|
+
@else
|
|
85
|
+
Inactive User
|
|
86
|
+
@endif
|
|
87
|
+
`.trim();
|
|
88
|
+
const ctx = {
|
|
89
|
+
user: {
|
|
90
|
+
isActive: true,
|
|
91
|
+
role: 'engineer',
|
|
92
|
+
projects: [
|
|
93
|
+
{
|
|
94
|
+
name: 'Relay Engine',
|
|
95
|
+
status: 'active',
|
|
96
|
+
tasks: [
|
|
97
|
+
{ title: 'Write lexer', priority: 'high' },
|
|
98
|
+
{ title: 'Write parser', priority: 'medium' }
|
|
99
|
+
]
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
name: 'Old Project',
|
|
103
|
+
status: 'archived',
|
|
104
|
+
tasks: []
|
|
105
|
+
}
|
|
106
|
+
]
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
const pipes = {
|
|
110
|
+
upper: (val) => val.toUpperCase(),
|
|
111
|
+
prefix: (val, p) => p + val
|
|
112
|
+
};
|
|
113
|
+
const expected = `
|
|
114
|
+
Active Project [>> ENGINEER]: Relay Engine
|
|
115
|
+
|
|
116
|
+
- Write lexer (HIGH)
|
|
117
|
+
|
|
118
|
+
- Write parser (MEDIUM)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
Archived Project [ENGINEER]: Old Project
|
|
122
|
+
`.replace(/^\s+/gm, '').trim();
|
|
123
|
+
const result = (0, template_engine_1.render)(tpl, ctx, pipes).replace(/^\s+/gm, '').trim();
|
|
124
|
+
expect(result).toBe(expected);
|
|
125
|
+
});
|
|
126
|
+
it('handles edge cases with empty lists and nested object paths', () => {
|
|
127
|
+
const tpl = `
|
|
128
|
+
@if (user.isNotSet)
|
|
129
|
+
Should not render
|
|
130
|
+
@else if (user.count === 0)
|
|
131
|
+
Zero Count: {{ user.metadata.tags[0] }}
|
|
132
|
+
@endif
|
|
133
|
+
@for (item of emptyList)
|
|
134
|
+
Should not render
|
|
135
|
+
@endfor
|
|
136
|
+
Done
|
|
137
|
+
`.trim();
|
|
138
|
+
const ctx = { user: { count: 0, metadata: { tags: ['admin'] } }, emptyList: [] };
|
|
139
|
+
expect((0, template_engine_1.render)(tpl, ctx).trim()).toBe('Zero Count: admin\nDone');
|
|
140
|
+
});
|
|
141
|
+
it('Deep nesting, parallel scopes, falsy values, pipe chains', () => {
|
|
142
|
+
const tpl = `
|
|
143
|
+
@const globalPrefix = ">>"
|
|
144
|
+
@if (payload.isValid === false)
|
|
145
|
+
Payload Invalid. Error: {{ payload.error | upper }}
|
|
146
|
+
@else
|
|
147
|
+
@for (org of payload.organizations)
|
|
148
|
+
ORGANIZATION: {{ org.name }}
|
|
149
|
+
@const orgStatus = org.isActive
|
|
150
|
+
@if (orgStatus)
|
|
151
|
+
@const memberCount = org.members.length
|
|
152
|
+
@if (memberCount === 0)
|
|
153
|
+
No members in active org.
|
|
154
|
+
@else
|
|
155
|
+
Members ({{ memberCount }}):
|
|
156
|
+
@for (member of org.members)
|
|
157
|
+
@const prefix = globalPrefix | concat: " " | concat: member.role
|
|
158
|
+
@if (member.isBanned)
|
|
159
|
+
[BANNED] {{ prefix }} - {{ member.name }}
|
|
160
|
+
@else if (member.age < 18)
|
|
161
|
+
[MINOR] {{ prefix }} - {{ member.name }}
|
|
162
|
+
@else
|
|
163
|
+
[ACTIVE] {{ prefix }} - {{ member.name }} | Data: {{ member.deep.missing.path.shouldNotCrash }}
|
|
164
|
+
@endif
|
|
165
|
+
@endfor
|
|
166
|
+
@endif
|
|
167
|
+
@else
|
|
168
|
+
Organization {{ org.name }} is INACTIVE.
|
|
169
|
+
@endif
|
|
170
|
+
---
|
|
171
|
+
@endfor
|
|
172
|
+
@endif
|
|
173
|
+
`.trim();
|
|
174
|
+
const ctx = {
|
|
175
|
+
payload: {
|
|
176
|
+
isValid: true,
|
|
177
|
+
error: null,
|
|
178
|
+
organizations: [
|
|
179
|
+
{
|
|
180
|
+
name: 'Alpha Corp',
|
|
181
|
+
isActive: false,
|
|
182
|
+
members: []
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
name: 'Beta LLC',
|
|
186
|
+
isActive: true,
|
|
187
|
+
members: []
|
|
188
|
+
},
|
|
189
|
+
{
|
|
190
|
+
name: 'Gamma Inc',
|
|
191
|
+
isActive: true,
|
|
192
|
+
members: [
|
|
193
|
+
{ name: 'Alice', role: 'ADMIN', isBanned: false, age: 30, deep: {} },
|
|
194
|
+
{ name: 'Bob', role: 'USER', isBanned: true, age: 25 },
|
|
195
|
+
{ name: 'Charlie', role: 'GUEST', isBanned: false, age: 16 }
|
|
196
|
+
]
|
|
197
|
+
}
|
|
198
|
+
]
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
const pipes = {
|
|
202
|
+
upper: (val) => val ? val.toUpperCase() : '',
|
|
203
|
+
concat: (val, extra) => val + extra
|
|
204
|
+
};
|
|
205
|
+
const expected = `
|
|
206
|
+
ORGANIZATION: Alpha Corp
|
|
207
|
+
Organization Alpha Corp is INACTIVE.
|
|
208
|
+
---
|
|
209
|
+
ORGANIZATION: Beta LLC
|
|
210
|
+
No members in active org.
|
|
211
|
+
---
|
|
212
|
+
ORGANIZATION: Gamma Inc
|
|
213
|
+
Members (3):
|
|
214
|
+
[ACTIVE] >> ADMIN - Alice | Data:
|
|
215
|
+
[BANNED] >> USER - Bob
|
|
216
|
+
[MINOR] >> GUEST - Charlie
|
|
217
|
+
---
|
|
218
|
+
`.trim().replace(/^\s+/gm, '');
|
|
219
|
+
const result = (0, template_engine_1.render)(tpl, ctx, pipes).trim().replace(/^\s+/gm, '');
|
|
220
|
+
expect(result).toBe(expected);
|
|
221
|
+
});
|
|
222
|
+
it('Resilience against malformed tokens (throws correctly or ignores)', () => {
|
|
223
|
+
const unclosedTpl = 'Hello {{ user.name';
|
|
224
|
+
expect(() => (0, template_engine_1.render)(unclosedTpl, {})).toThrow(/Unclosed interpolation/);
|
|
225
|
+
const malformedConst = '@const user.name = "bob"';
|
|
226
|
+
expect(() => (0, template_engine_1.render)(malformedConst, {})).toThrow(/Malformed @const/);
|
|
227
|
+
const badPipe = '{{ user.name | unknownPipe }}';
|
|
228
|
+
expect(() => (0, template_engine_1.render)(badPipe, { user: { name: 'bob' } })).toThrow(/Unknown pipe: unknownPipe/);
|
|
229
|
+
// Shadowing constant should throw
|
|
230
|
+
const shadowTpl = `
|
|
231
|
+
@const a = 1
|
|
232
|
+
@const a = 2
|
|
233
|
+
`;
|
|
234
|
+
expect(() => (0, template_engine_1.render)(shadowTpl, {})).toThrow(/Cannot shadow or redefine constant 'a'/);
|
|
235
|
+
});
|
|
236
|
+
it('Extreme Edge Cases & Parser Torment', () => {
|
|
237
|
+
// 1. Whitespace Chaos
|
|
238
|
+
const tpl1 = `
|
|
239
|
+
@if (
|
|
240
|
+
user . age
|
|
241
|
+
===
|
|
242
|
+
20
|
|
243
|
+
)
|
|
244
|
+
Yes
|
|
245
|
+
@endif`.trim();
|
|
246
|
+
expect((0, template_engine_1.render)(tpl1, { user: { age: 20 } }).trim()).toBe('Yes');
|
|
247
|
+
// 2. Computed Properties with crazy names
|
|
248
|
+
const tpl2 = `{{ data["crazy-key-with space"] }}`;
|
|
249
|
+
expect((0, template_engine_1.render)(tpl2, { data: { "crazy-key-with space": "works" } })).toBe('works');
|
|
250
|
+
// 3. String literals containing template syntax (Lexer should not interpolate inner strings)
|
|
251
|
+
const tpl3 = `{{ "{{ not an interpolation }}" }}`;
|
|
252
|
+
expect((0, template_engine_1.render)(tpl3, {})).toBe('{{ not an interpolation }}');
|
|
253
|
+
// 4. Dangling blocks
|
|
254
|
+
expect(() => (0, template_engine_1.render)('@else', {})).toThrow(/Unexpected block tag: @else/);
|
|
255
|
+
expect(() => (0, template_engine_1.render)('@endif', {})).toThrow(/Unexpected block tag: @endif/);
|
|
256
|
+
expect(() => (0, template_engine_1.render)('@endfor', {})).toThrow(/Unexpected block tag: @endfor/);
|
|
257
|
+
});
|
|
258
|
+
it('The Unhandled JS Idioms (Breaking the engine!)', () => {
|
|
259
|
+
// These are scenarios I know the strict Lexer cannot handle yet because they are complex JS idioms
|
|
260
|
+
// 1. Escaped Quotes inside strings: The lexer will stop at the first internal quote.
|
|
261
|
+
const tplEscaped = `{{ "He said \\"Hello\\"" }}`;
|
|
262
|
+
expect(() => (0, template_engine_1.render)(tplEscaped, {})).toThrow();
|
|
263
|
+
// 2. Negative Numbers: Lexer only recognizes digits, not the minus operator as part of a number or unary.
|
|
264
|
+
const tplNegative = `@const temp = -10`;
|
|
265
|
+
expect(() => (0, template_engine_1.render)(tplNegative, {})).toThrow(/Unexpected character in expression.*-/);
|
|
266
|
+
// 3. Unary Operators (boolean NOT):
|
|
267
|
+
const tplUnary = `@if (!user.isActive) \n inactive \n @endif`;
|
|
268
|
+
expect(() => (0, template_engine_1.render)(tplUnary, { user: { isActive: false } })).toThrow(/Unexpected character in expression.*!/);
|
|
269
|
+
// 4. Unorthodox 'of' usage in @for loops
|
|
270
|
+
const tplForOf = `@for (item of ["string of doom", "other"]) \n {{ item }} \n @endfor`;
|
|
271
|
+
// Throws because our strict expression parser intentionally does not support Array literals `[`
|
|
272
|
+
expect(() => (0, template_engine_1.render)(tplForOf, {})).toThrow();
|
|
273
|
+
// It works because indexOf(' of ') finds the first instance, but what if the item name has "of" with spaces around it?
|
|
274
|
+
// User names a variable `list of things`. Invalid identifier but interesting break!
|
|
275
|
+
expect(() => (0, template_engine_1.render)(`@for (list of things of items)`, {})).toThrow();
|
|
276
|
+
});
|
|
277
|
+
it('False positives and overlapping data structures', () => {
|
|
278
|
+
// 1. JSON-LD and Emails (False positive tags)
|
|
279
|
+
// The engine must NOT crash when encountering `@context`, `@id`, `user@email.com`, or `@iframe`.
|
|
280
|
+
const tpl1 = `{
|
|
281
|
+
"@context": "https://json-ld.org/contexts/person.jsonld",
|
|
282
|
+
"@id": "http://dbpedia.org/resource/John_Lennon",
|
|
283
|
+
"email": "john@beatles.com",
|
|
284
|
+
"handle": "@johnlennon",
|
|
285
|
+
"tagLike": "@iframe width=100"
|
|
286
|
+
}`;
|
|
287
|
+
expect((0, template_engine_1.render)(tpl1, {})).toBe(tpl1);
|
|
288
|
+
// 2. Data that generates another template (Template Inception)
|
|
289
|
+
const tpl2 = `
|
|
290
|
+
@const open = "{{"
|
|
291
|
+
@const close = "}}"
|
|
292
|
+
Code: {{ open }} user.name {{ close }}
|
|
293
|
+
`.trim();
|
|
294
|
+
expect((0, template_engine_1.render)(tpl2, {})).toBe('Code: {{ user.name }}');
|
|
295
|
+
// 3. String literals containing structural AST tokens
|
|
296
|
+
const tpl3 = `
|
|
297
|
+
@if (user.status === "ACTIVE (ignore this)")
|
|
298
|
+
@const message = "Status is: @if(true) nested @endif"
|
|
299
|
+
{{ message }}
|
|
300
|
+
@endif
|
|
301
|
+
`.trim();
|
|
302
|
+
expect((0, template_engine_1.render)(tpl3, { user: { status: "ACTIVE (ignore this)" } }).trim()).toBe("Status is: @if(true) nested @endif");
|
|
303
|
+
});
|
|
304
|
+
it('Syntax Polyglot & Cross-Language Interference', () => {
|
|
305
|
+
// These tests mix syntaxes from other engines (Jinja, Razor, Blade, Handlebars, JSX)
|
|
306
|
+
// and structural languages (Python, PHP, C#, Bash) to prove our engine only executes its own domain.
|
|
307
|
+
// (@Model.User)
|
|
308
|
+
// Our Lexer must ignore `@Model` because it doesn't match `@if`, `@for`, etc.
|
|
309
|
+
const razorTpl = `<div>@Model.UserName - @if(user.exists) FOUND @endif</div>`;
|
|
310
|
+
expect((0, template_engine_1.render)(razorTpl, { user: { exists: true } })).toBe('<div>@Model.UserName - FOUND </div>');
|
|
311
|
+
// ({% if %}, {# comment #})
|
|
312
|
+
const pythonTpl = `
|
|
313
|
+
def print_user():
|
|
314
|
+
# {% if user.is_active %}
|
|
315
|
+
print("{{ user.name }}") # Outputs native interpolation
|
|
316
|
+
# {% endif %}
|
|
317
|
+
@const result = user.name
|
|
318
|
+
return "{{ result }}"
|
|
319
|
+
`;
|
|
320
|
+
const expectedPython = `def print_user(): # {% if user.is_active %} print("Alice") # Outputs native interpolation # {% endif %} return "Alice"`;
|
|
321
|
+
expect((0, template_engine_1.render)(pythonTpl, { user: { name: "Alice" } }).replace(/\s+/g, ' ').trim()).toBe(expectedPython);
|
|
322
|
+
// ({user.name} vs {{user.name}})
|
|
323
|
+
// Only double braces get executed, single braces get passed as text.
|
|
324
|
+
const jsxTpl = `<div className={styles.container}>{user.name} is actually {{ user.name }}</div>`;
|
|
325
|
+
expect((0, template_engine_1.render)(jsxTpl, { user: { name: "Alice" } })).toBe('<div className={styles.container}>{user.name} is actually Alice</div>');
|
|
326
|
+
// Engine must ignore @foreach and <?php tags.
|
|
327
|
+
const bladeTpl = `
|
|
328
|
+
<?php echo $var; ?>
|
|
329
|
+
@foreach($users as $u)
|
|
330
|
+
@for (u of users)
|
|
331
|
+
{{ u }}
|
|
332
|
+
@endfor
|
|
333
|
+
@endforeach
|
|
334
|
+
`;
|
|
335
|
+
const expectedBlade = `<?php echo $var; ?> @foreach($users as $u) Bob @endforeach`;
|
|
336
|
+
expect((0, template_engine_1.render)(bladeTpl, { users: ["Bob"] }).replace(/\s+/g, ' ').trim()).toBe(expectedBlade);
|
|
337
|
+
// Bash script Interference ($USER, ${USER})
|
|
338
|
+
const bashTpl = `
|
|
339
|
+
#!/bin/bash
|
|
340
|
+
export THE_USER="{{ sys.user }}"
|
|
341
|
+
echo $THE_USER
|
|
342
|
+
echo \${THE_USER}
|
|
343
|
+
@if (sys.isRoot)
|
|
344
|
+
sudo rm -rf /
|
|
345
|
+
@endif
|
|
346
|
+
`;
|
|
347
|
+
const expectedBash = `#!/bin/bash export THE_USER="admin" echo $THE_USER echo \${THE_USER} sudo rm -rf /`;
|
|
348
|
+
expect((0, template_engine_1.render)(bashTpl, { sys: { user: "admin", isRoot: true } }).replace(/\s+/g, ' ').trim()).toBe(expectedBash);
|
|
349
|
+
// Triple Curly Interference ({{{ user.name }}})
|
|
350
|
+
// Emulates Vue's v-html raw injection braces.
|
|
351
|
+
// Our lexer matches the outermost {{ and }} and passes `{ param }` to the expression parser.
|
|
352
|
+
// Since our strict expression parser explicitly forbids JSON objects (no Javascript `{` or `}`), this safely throws a syntax error!
|
|
353
|
+
const handlebarsTpl = `{{{ param }}}`;
|
|
354
|
+
expect(() => (0, template_engine_1.render)(handlebarsTpl, { param: "value" })).toThrow(/Unexpected character in expression at index 0: \{/);
|
|
355
|
+
const erbTpl = `<%= user.id %> / <%- @for (num of arr) -%>{{ num }}<%- @endfor -%>`;
|
|
356
|
+
// Note: The space after `)` in `@for (num of arr) -%>` is preserved as text
|
|
357
|
+
const expectedErb = `<%= user.id %> / <%- -%>1<%- -%>`;
|
|
358
|
+
expect((0, template_engine_1.render)(erbTpl, { arr: [1] })).toBe(expectedErb);
|
|
359
|
+
const jsTpl = `
|
|
360
|
+
const greeting = \`Hello \${user.name}\`;
|
|
361
|
+
@const name = "Bob"
|
|
362
|
+
const override = \`Hello {{ name }}\`;
|
|
363
|
+
`;
|
|
364
|
+
const expectedJs = `const greeting = \`Hello \${user.name}\`; const override = \`Hello Bob\`;`;
|
|
365
|
+
expect((0, template_engine_1.render)(jsTpl, {}).replace(/\s+/g, ' ').trim()).toBe(expectedJs);
|
|
366
|
+
const mdTpl = `
|
|
367
|
+
\`\`\`ts
|
|
368
|
+
@for (item of items)
|
|
369
|
+
console.log("{{ item }}");
|
|
370
|
+
@endfor
|
|
371
|
+
\`\`\`
|
|
372
|
+
`;
|
|
373
|
+
const expectedMd = `\`\`\`ts console.log("X"); \`\`\``;
|
|
374
|
+
expect((0, template_engine_1.render)(mdTpl, { items: ["X"] }).replace(/\s+/g, ' ').trim()).toBe(expectedMd);
|
|
375
|
+
const javaTpl = `
|
|
376
|
+
@RestController
|
|
377
|
+
@RequestMapping("/api")
|
|
378
|
+
public class App {
|
|
379
|
+
@Autowired
|
|
380
|
+
private Service service;
|
|
381
|
+
// @if (java.generateSetter)
|
|
382
|
+
public void setService(Service s) { this.service = s; }
|
|
383
|
+
// @endif
|
|
384
|
+
}
|
|
385
|
+
`;
|
|
386
|
+
const expectedJava = `@RestController @RequestMapping("/api") public class App { @Autowired private Service service; // public void setService(Service s) { this.service = s; } // }`;
|
|
387
|
+
expect((0, template_engine_1.render)(javaTpl, { java: { generateSetter: true } }).replace(/\s+/g, ' ').trim()).toBe(expectedJava);
|
|
388
|
+
});
|
|
389
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
export type Context = Record<string, any>;
|
|
2
|
+
export type PipeFunction = (value: any, ...args: any[]) => any;
|
|
3
|
+
export type PipeRegistry = Record<string, PipeFunction>;
|
|
4
|
+
export type ExprToken = {
|
|
5
|
+
type: 'Identifier';
|
|
6
|
+
value: string;
|
|
7
|
+
} | {
|
|
8
|
+
type: 'Number';
|
|
9
|
+
value: number;
|
|
10
|
+
} | {
|
|
11
|
+
type: 'String';
|
|
12
|
+
value: string;
|
|
13
|
+
} | {
|
|
14
|
+
type: 'Operator';
|
|
15
|
+
value: string;
|
|
16
|
+
} | {
|
|
17
|
+
type: 'Punctuation';
|
|
18
|
+
value: string;
|
|
19
|
+
};
|
|
20
|
+
export declare function tokenizeExpression(source: string): ExprToken[];
|
|
21
|
+
export type ExprAST = {
|
|
22
|
+
type: 'Literal';
|
|
23
|
+
value: any;
|
|
24
|
+
} | {
|
|
25
|
+
type: 'Identifier';
|
|
26
|
+
name: string;
|
|
27
|
+
} | {
|
|
28
|
+
type: 'Member';
|
|
29
|
+
object: ExprAST;
|
|
30
|
+
property: string | ExprAST;
|
|
31
|
+
computed: boolean;
|
|
32
|
+
} | {
|
|
33
|
+
type: 'Binary';
|
|
34
|
+
operator: string;
|
|
35
|
+
left: ExprAST;
|
|
36
|
+
right: ExprAST;
|
|
37
|
+
} | {
|
|
38
|
+
type: 'Pipe';
|
|
39
|
+
base: ExprAST;
|
|
40
|
+
name: string;
|
|
41
|
+
args: ExprAST[];
|
|
42
|
+
};
|
|
43
|
+
export declare function parseExpression(source: string): ExprAST;
|
|
44
|
+
export type TemplateToken = {
|
|
45
|
+
type: 'TEXT';
|
|
46
|
+
value: string;
|
|
47
|
+
} | {
|
|
48
|
+
type: 'INTERPOLATION';
|
|
49
|
+
value: string;
|
|
50
|
+
} | {
|
|
51
|
+
type: 'TAG';
|
|
52
|
+
name: string;
|
|
53
|
+
inner: string | null;
|
|
54
|
+
};
|
|
55
|
+
export declare function tokenizeTemplate(source: string): TemplateToken[];
|
|
56
|
+
export type ASTNode = {
|
|
57
|
+
type: 'TEXT';
|
|
58
|
+
content: string;
|
|
59
|
+
} | {
|
|
60
|
+
type: 'INTERPOLATION';
|
|
61
|
+
expression: ExprAST;
|
|
62
|
+
} | {
|
|
63
|
+
type: 'CONST';
|
|
64
|
+
name: string;
|
|
65
|
+
expression: ExprAST;
|
|
66
|
+
} | {
|
|
67
|
+
type: 'IF';
|
|
68
|
+
condition: ExprAST;
|
|
69
|
+
consequence: ASTNode[];
|
|
70
|
+
alternate: ASTNode[] | null;
|
|
71
|
+
} | {
|
|
72
|
+
type: 'FOR';
|
|
73
|
+
itemName: string;
|
|
74
|
+
listExpression: ExprAST;
|
|
75
|
+
body: ASTNode[];
|
|
76
|
+
};
|
|
77
|
+
export declare function parseTemplate(tokens: TemplateToken[]): ASTNode[];
|
|
78
|
+
export declare function evaluateExprAST(ast: ExprAST, context: Context, pipes?: PipeRegistry): any;
|
|
79
|
+
export declare function evaluateTemplate(nodes: ASTNode[], context: Context, pipes?: PipeRegistry): string;
|
|
80
|
+
export declare function render(templateStr: string, context: Context, pipes?: PipeRegistry): string;
|