@tsomaiatech/moxite 1.0.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.
Files changed (22) hide show
  1. package/README.md +61 -0
  2. package/idea-plugin/build.gradle.kts +60 -0
  3. package/idea-plugin/gradle.properties +14 -0
  4. package/idea-plugin/settings.gradle.kts +1 -0
  5. package/idea-plugin/src/main/flex/Moxite.flex +101 -0
  6. package/idea-plugin/src/main/grammar/Moxite.bnf +79 -0
  7. package/idea-plugin/src/main/java/tech/tsomaia/moxite/idea/MoxiteFileType.java +39 -0
  8. package/idea-plugin/src/main/java/tech/tsomaia/moxite/idea/MoxiteIcons.java +9 -0
  9. package/idea-plugin/src/main/java/tech/tsomaia/moxite/idea/MoxiteLanguage.java +11 -0
  10. package/idea-plugin/src/main/java/tech/tsomaia/moxite/idea/MoxiteParserDefinition.java +77 -0
  11. package/idea-plugin/src/main/java/tech/tsomaia/moxite/idea/MoxiteSyntaxHighlighter.java +82 -0
  12. package/idea-plugin/src/main/java/tech/tsomaia/moxite/idea/MoxiteSyntaxHighlighterFactory.java +16 -0
  13. package/idea-plugin/src/main/java/tech/tsomaia/moxite/idea/lexer/MoxiteLexerAdapter.java +9 -0
  14. package/idea-plugin/src/main/java/tech/tsomaia/moxite/idea/parser/MoxiteParserUtil.java +6 -0
  15. package/idea-plugin/src/main/java/tech/tsomaia/moxite/idea/psi/MoxiteElementType.java +12 -0
  16. package/idea-plugin/src/main/java/tech/tsomaia/moxite/idea/psi/MoxiteFile.java +25 -0
  17. package/idea-plugin/src/main/java/tech/tsomaia/moxite/idea/psi/MoxiteTokenType.java +17 -0
  18. package/idea-plugin/src/main/resources/META-INF/plugin.xml +32 -0
  19. package/package.json +36 -0
  20. package/src/__tests__/template-engine.test.ts +437 -0
  21. package/src/template-engine.ts +480 -0
  22. package/src/template-manager.ts +75 -0
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@tsomaiatech/moxite",
3
+ "version": "1.0.0",
4
+ "description": "Moxite template language",
5
+ "keywords": [
6
+ "moxite",
7
+ "template",
8
+ "templating",
9
+ "language",
10
+ "template-engine"
11
+ ],
12
+ "homepage": "https://github.com/Tsomaia-Technologies/moxite#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/Tsomaia-Technologies/moxite/issues"
15
+ },
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+ssh://git@github.com/Tsomaia-Technologies/moxite.git"
19
+ },
20
+ "license": "MIT",
21
+ "author": "Tsomaia Technologies",
22
+ "type": "commonjs",
23
+ "main": "dist/index.js",
24
+ "scripts": {
25
+ "test": "jest"
26
+ },
27
+ "devDependencies": {
28
+ "jest": "^30.2.0",
29
+ "ts-jest": "^29.4.6",
30
+ "tsx": "^4.7.0",
31
+ "typescript": "^5.3.3"
32
+ },
33
+ "publishConfig": {
34
+ "access": "public"
35
+ }
36
+ }
@@ -0,0 +1,437 @@
1
+ import { render } from '../template-engine'
2
+
3
+ describe('Template Engine', () => {
4
+ it('renders text only', () => {
5
+ const tpl = 'Hello world'
6
+ expect(render(tpl, {})).toBe('Hello world')
7
+ })
8
+
9
+ it('handles interpolation & implicit safe chaining', () => {
10
+ const tpl = 'Hello {{ user.profile.name }}!'
11
+ const ctx = { user: { profile: { name: 'Alice' } } }
12
+ expect(render(tpl, ctx)).toBe('Hello Alice!')
13
+
14
+ // Safe chaining
15
+ const ctxBad = {}
16
+ expect(render(tpl, ctxBad)).toBe('Hello !')
17
+ })
18
+
19
+ it('applies pipes correctly', () => {
20
+ const tpl = 'Hello {{ name | upper }} - {{ num | add: "5" }}'
21
+ const ctx = { name: 'bob', num: 10 }
22
+ const pipes = {
23
+ upper: (val: string) => val.toUpperCase(),
24
+ add: (val: number, amt: string) => val + Number(amt)
25
+ }
26
+ expect(render(tpl, ctx, pipes)).toBe('Hello BOB - 15')
27
+ })
28
+
29
+ it('evaluates @if / @else if / @else', () => {
30
+ const tpl = `
31
+ @if (user.isAdmin)
32
+ Admin
33
+ @else if (user.isGuest === "yes")
34
+ Guest
35
+ @else if (user.age > 18)
36
+ Adult
37
+ @else
38
+ User
39
+ @endif
40
+ `.trim()
41
+
42
+ expect(render(tpl, { user: { isAdmin: true } }).trim()).toBe('Admin')
43
+ expect(render(tpl, { user: { isGuest: "yes" } }).trim()).toBe('Guest')
44
+ expect(render(tpl, { user: { age: 20 } }).trim()).toBe('Adult')
45
+ expect(render(tpl, { user: { age: 10 } }).trim()).toBe('User')
46
+ })
47
+
48
+ it('runs @for loops', () => {
49
+ const tpl = `
50
+ @for (item of items)
51
+ - {{ item.name }}
52
+ @endfor
53
+ `.trim()
54
+
55
+ const ctx = { items: [{ name: 'A' }, { name: 'B' }] }
56
+ expect(render(tpl, ctx).trim()).toBe('- A\n- B')
57
+ })
58
+
59
+ it('supports @const block-level scoping', () => {
60
+ const tpl = `
61
+ @const a = 1
62
+ @if (true)
63
+ @const b = 2
64
+ {{ a }} - {{ b }}
65
+ @endif
66
+ @if (true)
67
+ @const b = 3
68
+ {{ a }} - {{ b }}
69
+ @endif
70
+ `.trim()
71
+
72
+ const ctx = {}
73
+ expect(render(tpl, ctx).trim()).toBe('1 - 2\n1 - 3')
74
+ })
75
+
76
+ it('executes complex battle test (Nested @for, @if, @const, Pipes)', () => {
77
+ const tpl = `
78
+ @const roleName = user.role
79
+ @if (user.isActive)
80
+ @for (project of user.projects)
81
+ @const projectStatus = project.status
82
+ @if (projectStatus === "active")
83
+ Active Project [{{ roleName | upper | prefix: ">> " }}]: {{ project.name }}
84
+ @for (task of project.tasks)
85
+ - {{ task.title }} ({{ task.priority | upper }})
86
+ @endfor
87
+ @else if (projectStatus === "archived")
88
+ Archived Project [{{ roleName | upper }}]: {{ project.name }}
89
+ @else
90
+ Unknown Project Status
91
+ @endif
92
+ @endfor
93
+ @else
94
+ Inactive User
95
+ @endif
96
+ `.trim()
97
+
98
+ const ctx = {
99
+ user: {
100
+ isActive: true,
101
+ role: 'engineer',
102
+ projects: [
103
+ {
104
+ name: 'Relay Engine',
105
+ status: 'active',
106
+ tasks: [
107
+ { title: 'Write lexer', priority: 'high' },
108
+ { title: 'Write parser', priority: 'medium' }
109
+ ]
110
+ },
111
+ {
112
+ name: 'Old Project',
113
+ status: 'archived',
114
+ tasks: []
115
+ }
116
+ ]
117
+ }
118
+ }
119
+
120
+ const pipes = {
121
+ upper: (val: string) => val.toUpperCase(),
122
+ prefix: (val: string, p: string) => p + val
123
+ }
124
+
125
+ const expected = `
126
+ Active Project [>> ENGINEER]: Relay Engine
127
+
128
+ - Write lexer (HIGH)
129
+
130
+ - Write parser (MEDIUM)
131
+
132
+
133
+ Archived Project [ENGINEER]: Old Project
134
+ `.replace(/^\s+/gm, '').trim()
135
+
136
+ const result = render(tpl, ctx, pipes).replace(/^\s+/gm, '').trim()
137
+ expect(result).toBe(expected)
138
+ })
139
+
140
+ it('handles edge cases with empty lists and nested object paths', () => {
141
+ const tpl = `
142
+ @if (user.isNotSet)
143
+ Should not render
144
+ @else if (user.count === 0)
145
+ Zero Count: {{ user.metadata.tags[0] }}
146
+ @endif
147
+ @for (item of emptyList)
148
+ Should not render
149
+ @endfor
150
+ Done
151
+ `.trim()
152
+
153
+ const ctx = { user: { count: 0, metadata: { tags: ['admin'] } }, emptyList: [] }
154
+ expect(render(tpl, ctx).trim()).toBe('Zero Count: admin\nDone')
155
+ })
156
+
157
+ it('Deep nesting, parallel scopes, falsy values, pipe chains', () => {
158
+ const tpl = `
159
+ @const globalPrefix = ">>"
160
+ @if (payload.isValid === false)
161
+ Payload Invalid. Error: {{ payload.error | upper }}
162
+ @else
163
+ @for (org of payload.organizations)
164
+ ORGANIZATION: {{ org.name }}
165
+ @const orgStatus = org.isActive
166
+ @if (orgStatus)
167
+ @const memberCount = org.members.length
168
+ @if (memberCount === 0)
169
+ No members in active org.
170
+ @else
171
+ Members ({{ memberCount }}):
172
+ @for (member of org.members)
173
+ @const prefix = globalPrefix | concat: " " | concat: member.role
174
+ @if (member.isBanned)
175
+ [BANNED] {{ prefix }} - {{ member.name }}
176
+ @else if (member.age < 18)
177
+ [MINOR] {{ prefix }} - {{ member.name }}
178
+ @else
179
+ [ACTIVE] {{ prefix }} - {{ member.name }} | Data: {{ member.deep.missing.path.shouldNotCrash }}
180
+ @endif
181
+ @endfor
182
+ @endif
183
+ @else
184
+ Organization {{ org.name }} is INACTIVE.
185
+ @endif
186
+ ---
187
+ @endfor
188
+ @endif
189
+ `.trim()
190
+
191
+ const ctx = {
192
+ payload: {
193
+ isValid: true,
194
+ error: null,
195
+ organizations: [
196
+ {
197
+ name: 'Alpha Corp',
198
+ isActive: false,
199
+ members: []
200
+ },
201
+ {
202
+ name: 'Beta LLC',
203
+ isActive: true,
204
+ members: []
205
+ },
206
+ {
207
+ name: 'Gamma Inc',
208
+ isActive: true,
209
+ members: [
210
+ { name: 'Alice', role: 'ADMIN', isBanned: false, age: 30, deep: {} },
211
+ { name: 'Bob', role: 'USER', isBanned: true, age: 25 },
212
+ { name: 'Charlie', role: 'GUEST', isBanned: false, age: 16 }
213
+ ]
214
+ }
215
+ ]
216
+ }
217
+ }
218
+
219
+ const pipes = {
220
+ upper: (val: string) => val ? val.toUpperCase() : '',
221
+ concat: (val: string, extra: string) => val + extra
222
+ }
223
+
224
+ const expected = `
225
+ ORGANIZATION: Alpha Corp
226
+ Organization Alpha Corp is INACTIVE.
227
+ ---
228
+ ORGANIZATION: Beta LLC
229
+ No members in active org.
230
+ ---
231
+ ORGANIZATION: Gamma Inc
232
+ Members (3):
233
+ [ACTIVE] >> ADMIN - Alice | Data:
234
+ [BANNED] >> USER - Bob
235
+ [MINOR] >> GUEST - Charlie
236
+ ---
237
+ `.trim().replace(/^\s+/gm, '')
238
+
239
+ const result = render(tpl, ctx, pipes).trim().replace(/^\s+/gm, '')
240
+ expect(result).toBe(expected)
241
+ })
242
+
243
+ it('Resilience against malformed tokens (throws correctly or ignores)', () => {
244
+ const unclosedTpl = 'Hello {{ user.name'
245
+ expect(() => render(unclosedTpl, {})).toThrow(/Unclosed interpolation/)
246
+
247
+ const malformedConst = '@const user.name = "bob"'
248
+ expect(() => render(malformedConst, {})).toThrow(/Malformed @const/)
249
+
250
+ const badPipe = '{{ user.name | unknownPipe }}'
251
+ expect(() => render(badPipe, { user: { name: 'bob' } })).toThrow(/Unknown pipe: unknownPipe/)
252
+
253
+ // Shadowing constant should throw
254
+ const shadowTpl = `
255
+ @const a = 1
256
+ @const a = 2
257
+ `
258
+ expect(() => render(shadowTpl, {})).toThrow(/Cannot shadow or redefine constant 'a'/)
259
+ })
260
+
261
+ it('Extreme Edge Cases & Parser Torment', () => {
262
+ // 1. Whitespace Chaos
263
+ const tpl1 = `
264
+ @if (
265
+ user . age
266
+ ===
267
+ 20
268
+ )
269
+ Yes
270
+ @endif`.trim()
271
+ expect(render(tpl1, { user: { age: 20 } }).trim()).toBe('Yes')
272
+
273
+ // 2. Computed Properties with crazy names
274
+ const tpl2 = `{{ data["crazy-key-with space"] }}`
275
+ expect(render(tpl2, { data: { "crazy-key-with space": "works" } })).toBe('works')
276
+
277
+ // 3. String literals containing template syntax (Lexer should not interpolate inner strings)
278
+ const tpl3 = `{{ "{{ not an interpolation }}" }}`
279
+ expect(render(tpl3, {})).toBe('{{ not an interpolation }}')
280
+
281
+ // 4. Dangling blocks
282
+ expect(() => render('@else', {})).toThrow(/Unexpected block tag: @else/)
283
+ expect(() => render('@endif', {})).toThrow(/Unexpected block tag: @endif/)
284
+ expect(() => render('@endfor', {})).toThrow(/Unexpected block tag: @endfor/)
285
+ })
286
+
287
+ it('The Unhandled JS Idioms (Breaking the engine!)', () => {
288
+ // These are scenarios I know the strict Lexer cannot handle yet because they are complex JS idioms
289
+
290
+ // 1. Escaped Quotes inside strings: The lexer will stop at the first internal quote.
291
+ const tplEscaped = `{{ "He said \\"Hello\\"" }}`
292
+ expect(() => render(tplEscaped, {})).toThrow()
293
+
294
+ // 2. Negative Numbers: Lexer only recognizes digits, not the minus operator as part of a number or unary.
295
+ const tplNegative = `@const temp = -10`
296
+ expect(() => render(tplNegative, {})).toThrow(/Unexpected character in expression.*-/)
297
+
298
+ // 3. Unary Operators (boolean NOT):
299
+ const tplUnary = `@if (!user.isActive) \n inactive \n @endif`
300
+ expect(() => render(tplUnary, { user: { isActive: false } })).toThrow(/Unexpected character in expression.*!/)
301
+
302
+ // 4. Unorthodox 'of' usage in @for loops
303
+ const tplForOf = `@for (item of ["string of doom", "other"]) \n {{ item }} \n @endfor`
304
+ // Throws because our strict expression parser intentionally does not support Array literals `[`
305
+ expect(() => render(tplForOf, {})).toThrow()
306
+
307
+ // It works because indexOf(' of ') finds the first instance, but what if the item name has "of" with spaces around it?
308
+ // User names a variable `list of things`. Invalid identifier but interesting break!
309
+ expect(() => render(`@for (list of things of items)`, {})).toThrow()
310
+ })
311
+
312
+ it('False positives and overlapping data structures', () => {
313
+ // 1. JSON-LD and Emails (False positive tags)
314
+ // The engine must NOT crash when encountering `@context`, `@id`, `user@email.com`, or `@iframe`.
315
+ const tpl1 = `{
316
+ "@context": "https://json-ld.org/contexts/person.jsonld",
317
+ "@id": "http://dbpedia.org/resource/John_Lennon",
318
+ "email": "john@beatles.com",
319
+ "handle": "@johnlennon",
320
+ "tagLike": "@iframe width=100"
321
+ }`
322
+ expect(render(tpl1, {})).toBe(tpl1)
323
+
324
+ // 2. Data that generates another template (Template Inception)
325
+ const tpl2 = `
326
+ @const open = "{{"
327
+ @const close = "}}"
328
+ Code: {{ open }} user.name {{ close }}
329
+ `.trim()
330
+ expect(render(tpl2, {})).toBe('Code: {{ user.name }}')
331
+
332
+ // 3. String literals containing structural AST tokens
333
+ const tpl3 = `
334
+ @if (user.status === "ACTIVE (ignore this)")
335
+ @const message = "Status is: @if(true) nested @endif"
336
+ {{ message }}
337
+ @endif
338
+ `.trim()
339
+ expect(render(tpl3, { user: { status: "ACTIVE (ignore this)" } }).trim()).toBe("Status is: @if(true) nested @endif")
340
+ })
341
+
342
+ it('Syntax Polyglot & Cross-Language Interference', () => {
343
+ // These tests mix syntaxes from other engines (Jinja, Razor, Blade, Handlebars, JSX)
344
+ // and structural languages (Python, PHP, C#, Bash) to prove our engine only executes its own domain.
345
+
346
+ // (@Model.User)
347
+ // Our Lexer must ignore `@Model` because it doesn't match `@if`, `@for`, etc.
348
+ const razorTpl = `<div>@Model.UserName - @if(user.exists) FOUND @endif</div>`
349
+ expect(render(razorTpl, { user: { exists: true } })).toBe('<div>@Model.UserName - FOUND </div>')
350
+
351
+ // ({% if %}, {# comment #})
352
+ const pythonTpl = `
353
+ def print_user():
354
+ # {% if user.is_active %}
355
+ print("{{ user.name }}") # Outputs native interpolation
356
+ # {% endif %}
357
+ @const result = user.name
358
+ return "{{ result }}"
359
+ `
360
+ const expectedPython = `def print_user(): # {% if user.is_active %} print("Alice") # Outputs native interpolation # {% endif %} return "Alice"`
361
+ expect(render(pythonTpl, { user: { name: "Alice" } }).replace(/\s+/g, ' ').trim()).toBe(expectedPython)
362
+
363
+ // ({user.name} vs {{user.name}})
364
+ // Only double braces get executed, single braces get passed as text.
365
+ const jsxTpl = `<div className={styles.container}>{user.name} is actually {{ user.name }}</div>`
366
+ expect(render(jsxTpl, { user: { name: "Alice" } })).toBe('<div className={styles.container}>{user.name} is actually Alice</div>')
367
+
368
+ // Engine must ignore @foreach and <?php tags.
369
+ const bladeTpl = `
370
+ <?php echo $var; ?>
371
+ @foreach($users as $u)
372
+ @for (u of users)
373
+ {{ u }}
374
+ @endfor
375
+ @endforeach
376
+ `
377
+ const expectedBlade = `<?php echo $var; ?> @foreach($users as $u) Bob @endforeach`
378
+ expect(render(bladeTpl, { users: ["Bob"] }).replace(/\s+/g, ' ').trim()).toBe(expectedBlade)
379
+
380
+ // Bash script Interference ($USER, ${USER})
381
+ const bashTpl = `
382
+ #!/bin/bash
383
+ export THE_USER="{{ sys.user }}"
384
+ echo $THE_USER
385
+ echo \${THE_USER}
386
+ @if (sys.isRoot)
387
+ sudo rm -rf /
388
+ @endif
389
+ `
390
+ const expectedBash = `#!/bin/bash export THE_USER="admin" echo $THE_USER echo \${THE_USER} sudo rm -rf /`
391
+ expect(render(bashTpl, { sys: { user: "admin", isRoot: true } }).replace(/\s+/g, ' ').trim()).toBe(expectedBash)
392
+
393
+ // Triple Curly Interference ({{{ user.name }}})
394
+ // Emulates Vue's v-html raw injection braces.
395
+ // Our lexer matches the outermost {{ and }} and passes `{ param }` to the expression parser.
396
+ // Since our strict expression parser explicitly forbids JSON objects (no Javascript `{` or `}`), this safely throws a syntax error!
397
+ const handlebarsTpl = `{{{ param }}}`
398
+ expect(() => render(handlebarsTpl, { param: "value" })).toThrow(/Unexpected character in expression at index 0: \{/)
399
+
400
+ const erbTpl = `<%= user.id %> / <%- @for (num of arr) -%>{{ num }}<%- @endfor -%>`
401
+ // Note: The space after `)` in `@for (num of arr) -%>` is preserved as text
402
+ const expectedErb = `<%= user.id %> / <%- -%>1<%- -%>`
403
+ expect(render(erbTpl, { arr: [1] })).toBe(expectedErb)
404
+
405
+ const jsTpl = `
406
+ const greeting = \`Hello \${user.name}\`;
407
+ @const name = "Bob"
408
+ const override = \`Hello {{ name }}\`;
409
+ `
410
+ const expectedJs = `const greeting = \`Hello \${user.name}\`; const override = \`Hello Bob\`;`
411
+ expect(render(jsTpl, {}).replace(/\s+/g, ' ').trim()).toBe(expectedJs)
412
+
413
+ const mdTpl = `
414
+ \`\`\`ts
415
+ @for (item of items)
416
+ console.log("{{ item }}");
417
+ @endfor
418
+ \`\`\`
419
+ `
420
+ const expectedMd = `\`\`\`ts console.log("X"); \`\`\``
421
+ expect(render(mdTpl, { items: ["X"] }).replace(/\s+/g, ' ').trim()).toBe(expectedMd)
422
+
423
+ const javaTpl = `
424
+ @RestController
425
+ @RequestMapping("/api")
426
+ public class App {
427
+ @Autowired
428
+ private Service service;
429
+ // @if (java.generateSetter)
430
+ public void setService(Service s) { this.service = s; }
431
+ // @endif
432
+ }
433
+ `
434
+ const expectedJava = `@RestController @RequestMapping("/api") public class App { @Autowired private Service service; // public void setService(Service s) { this.service = s; } // }`
435
+ expect(render(javaTpl, { java: { generateSetter: true } }).replace(/\s+/g, ' ').trim()).toBe(expectedJava)
436
+ })
437
+ })