@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.
- package/README.md +61 -0
- package/idea-plugin/build.gradle.kts +60 -0
- package/idea-plugin/gradle.properties +14 -0
- package/idea-plugin/settings.gradle.kts +1 -0
- package/idea-plugin/src/main/flex/Moxite.flex +101 -0
- package/idea-plugin/src/main/grammar/Moxite.bnf +79 -0
- package/idea-plugin/src/main/java/tech/tsomaia/moxite/idea/MoxiteFileType.java +39 -0
- package/idea-plugin/src/main/java/tech/tsomaia/moxite/idea/MoxiteIcons.java +9 -0
- package/idea-plugin/src/main/java/tech/tsomaia/moxite/idea/MoxiteLanguage.java +11 -0
- package/idea-plugin/src/main/java/tech/tsomaia/moxite/idea/MoxiteParserDefinition.java +77 -0
- package/idea-plugin/src/main/java/tech/tsomaia/moxite/idea/MoxiteSyntaxHighlighter.java +82 -0
- package/idea-plugin/src/main/java/tech/tsomaia/moxite/idea/MoxiteSyntaxHighlighterFactory.java +16 -0
- package/idea-plugin/src/main/java/tech/tsomaia/moxite/idea/lexer/MoxiteLexerAdapter.java +9 -0
- package/idea-plugin/src/main/java/tech/tsomaia/moxite/idea/parser/MoxiteParserUtil.java +6 -0
- package/idea-plugin/src/main/java/tech/tsomaia/moxite/idea/psi/MoxiteElementType.java +12 -0
- package/idea-plugin/src/main/java/tech/tsomaia/moxite/idea/psi/MoxiteFile.java +25 -0
- package/idea-plugin/src/main/java/tech/tsomaia/moxite/idea/psi/MoxiteTokenType.java +17 -0
- package/idea-plugin/src/main/resources/META-INF/plugin.xml +32 -0
- package/package.json +36 -0
- package/src/__tests__/template-engine.test.ts +437 -0
- package/src/template-engine.ts +480 -0
- package/src/template-manager.ts +75 -0
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
export type Context = Record<string, any>
|
|
2
|
+
export type PipeFunction = (value: any, ...args: any[]) => any
|
|
3
|
+
export type PipeRegistry = Record<string, PipeFunction>
|
|
4
|
+
|
|
5
|
+
// ── Expression Lexer ──────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
export type ExprToken =
|
|
8
|
+
| { type: 'Identifier'; value: string }
|
|
9
|
+
| { type: 'Number'; value: number }
|
|
10
|
+
| { type: 'String'; value: string }
|
|
11
|
+
| { type: 'Operator'; value: string }
|
|
12
|
+
| { type: 'Punctuation'; value: string }
|
|
13
|
+
|
|
14
|
+
function isWhitespace(c: string) { return c === ' ' || c === '\n' || c === '\t' || c === '\r' }
|
|
15
|
+
function isAlpha(c: string) { return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c === '_' || c === '$' }
|
|
16
|
+
function isDigit(c: string) { return (c >= '0' && c <= '9') }
|
|
17
|
+
|
|
18
|
+
export function tokenizeExpression(source: string): ExprToken[] {
|
|
19
|
+
const tokens: ExprToken[] = []
|
|
20
|
+
let cursor = 0
|
|
21
|
+
const length = source.length
|
|
22
|
+
|
|
23
|
+
while (cursor < length) {
|
|
24
|
+
let char = source[cursor]
|
|
25
|
+
if (isWhitespace(char)) { cursor++; continue }
|
|
26
|
+
|
|
27
|
+
if (char === '"' || char === "'") {
|
|
28
|
+
const quote = char
|
|
29
|
+
let str = ''
|
|
30
|
+
cursor++
|
|
31
|
+
while (cursor < length && source[cursor] !== quote) {
|
|
32
|
+
str += source[cursor]
|
|
33
|
+
cursor++
|
|
34
|
+
}
|
|
35
|
+
cursor++
|
|
36
|
+
tokens.push({ type: 'String', value: str })
|
|
37
|
+
continue
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (isDigit(char)) {
|
|
41
|
+
let numStr = ''
|
|
42
|
+
while (cursor < length && (isDigit(source[cursor]) || source[cursor] === '.')) {
|
|
43
|
+
numStr += source[cursor]
|
|
44
|
+
cursor++
|
|
45
|
+
}
|
|
46
|
+
tokens.push({ type: 'Number', value: parseFloat(numStr) })
|
|
47
|
+
continue
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (isAlpha(char)) {
|
|
51
|
+
let id = ''
|
|
52
|
+
while (cursor < length && (isAlpha(source[cursor]) || isDigit(source[cursor]))) {
|
|
53
|
+
id += source[cursor]
|
|
54
|
+
cursor++
|
|
55
|
+
}
|
|
56
|
+
tokens.push({ type: 'Identifier', value: id })
|
|
57
|
+
continue
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (char === '(' || char === ')' || char === '[' || char === ']' || char === '.' || char === ',' || char === ':') {
|
|
61
|
+
tokens.push({ type: 'Punctuation', value: char })
|
|
62
|
+
cursor++
|
|
63
|
+
continue
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const three = source.slice(cursor, cursor + 3)
|
|
67
|
+
if (three === '===' || three === '!==') {
|
|
68
|
+
tokens.push({ type: 'Operator', value: three })
|
|
69
|
+
cursor += 3
|
|
70
|
+
continue
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const two = source.slice(cursor, cursor + 2)
|
|
74
|
+
if (two === '==' || two === '!=' || two === '<=' || two === '>=') {
|
|
75
|
+
tokens.push({ type: 'Operator', value: two })
|
|
76
|
+
cursor += 2
|
|
77
|
+
continue
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (char === '<' || char === '>' || char === '=' || char === '|') {
|
|
81
|
+
tokens.push({ type: 'Operator', value: char })
|
|
82
|
+
cursor++
|
|
83
|
+
continue
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
throw new Error(`Unexpected character in expression at index ${cursor}: ${char}`)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return tokens
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ── Expression Parser ─────────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
export type ExprAST =
|
|
95
|
+
| { type: 'Literal'; value: any }
|
|
96
|
+
| { type: 'Identifier'; name: string }
|
|
97
|
+
| { type: 'Member'; object: ExprAST; property: string | ExprAST; computed: boolean }
|
|
98
|
+
| { type: 'Binary'; operator: string; left: ExprAST; right: ExprAST }
|
|
99
|
+
| { type: 'Pipe'; base: ExprAST; name: string; args: ExprAST[] }
|
|
100
|
+
|
|
101
|
+
export function parseExpression(source: string): ExprAST {
|
|
102
|
+
const tokens = tokenizeExpression(source)
|
|
103
|
+
let pos = 0
|
|
104
|
+
|
|
105
|
+
function peek(): ExprToken | null { return pos < tokens.length ? tokens[pos] : null }
|
|
106
|
+
function consume(): ExprToken { return tokens[pos++] }
|
|
107
|
+
|
|
108
|
+
function matchType(type: 'Identifier'): { type: 'Identifier', value: string } | null
|
|
109
|
+
function matchType(type: 'Number'): { type: 'Number', value: number } | null
|
|
110
|
+
function matchType(type: 'String'): { type: 'String', value: string } | null
|
|
111
|
+
function matchType(type: ExprToken['type']): ExprToken | null {
|
|
112
|
+
const p = peek(); if (p && p.type === type) return consume(); return null
|
|
113
|
+
}
|
|
114
|
+
function matchOp(op: string): ExprToken | null {
|
|
115
|
+
const p = peek(); if (p && p.type === 'Operator' && p.value === op) return consume(); return null
|
|
116
|
+
}
|
|
117
|
+
function matchPunc(punc: string): ExprToken | null {
|
|
118
|
+
const p = peek(); if (p && p.type === 'Punctuation' && p.value === punc) return consume(); return null
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function parsePipe(): ExprAST {
|
|
122
|
+
let base = parseEquality()
|
|
123
|
+
while (matchOp('|')) {
|
|
124
|
+
const id = matchType('Identifier')
|
|
125
|
+
if (!id) throw new Error("Expected identifier after pipe '|'")
|
|
126
|
+
const args: ExprAST[] = []
|
|
127
|
+
if (matchPunc(':')) {
|
|
128
|
+
args.push(parseEquality())
|
|
129
|
+
while (matchPunc(',')) args.push(parseEquality())
|
|
130
|
+
}
|
|
131
|
+
base = { type: 'Pipe', base, name: id.value, args }
|
|
132
|
+
}
|
|
133
|
+
return base
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function parseEquality(): ExprAST {
|
|
137
|
+
let left = parsePrimary()
|
|
138
|
+
while (true) {
|
|
139
|
+
const p = peek()
|
|
140
|
+
if (p && p.type === 'Operator' && ['===', '!==', '==', '!=', '<', '>', '<=', '>='].includes(p.value)) {
|
|
141
|
+
const op = consume().value as string
|
|
142
|
+
const right = parsePrimary()
|
|
143
|
+
left = { type: 'Binary', operator: op, left, right }
|
|
144
|
+
} else break
|
|
145
|
+
}
|
|
146
|
+
return left
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function parseBase(): ExprAST {
|
|
150
|
+
if (matchPunc('(')) {
|
|
151
|
+
const expr = parsePipe()
|
|
152
|
+
if (!matchPunc(')')) throw new Error("Expected ')'")
|
|
153
|
+
return expr
|
|
154
|
+
}
|
|
155
|
+
const str = matchType('String')
|
|
156
|
+
if (str) return { type: 'Literal', value: str.value }
|
|
157
|
+
const num = matchType('Number')
|
|
158
|
+
if (num) return { type: 'Literal', value: num.value }
|
|
159
|
+
const id = matchType('Identifier')
|
|
160
|
+
if (id) {
|
|
161
|
+
if (id.value === 'true') return { type: 'Literal', value: true }
|
|
162
|
+
if (id.value === 'false') return { type: 'Literal', value: false }
|
|
163
|
+
if (id.value === 'null') return { type: 'Literal', value: null }
|
|
164
|
+
if (id.value === 'undefined') return { type: 'Literal', value: undefined }
|
|
165
|
+
return { type: 'Identifier', name: id.value }
|
|
166
|
+
}
|
|
167
|
+
throw new Error(`Unexpected token in expression: ${JSON.stringify(peek())}`)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function parsePrimary(): ExprAST {
|
|
171
|
+
let expr = parseBase()
|
|
172
|
+
while (true) {
|
|
173
|
+
if (matchPunc('.')) {
|
|
174
|
+
const prop = matchType('Identifier')
|
|
175
|
+
if (!prop) throw new Error("Expected identifier after '.'")
|
|
176
|
+
expr = { type: 'Member', object: expr, property: prop.value, computed: false }
|
|
177
|
+
} else if (matchPunc('[')) {
|
|
178
|
+
const computedProp = parsePipe()
|
|
179
|
+
if (!matchPunc(']')) throw new Error("Expected ']'")
|
|
180
|
+
expr = { type: 'Member', object: expr, property: computedProp, computed: true }
|
|
181
|
+
} else break
|
|
182
|
+
}
|
|
183
|
+
return expr
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const ast = parsePipe()
|
|
187
|
+
if (pos < tokens.length) throw new Error(`Unexpected extra tokens in expression: ${JSON.stringify(tokens.slice(pos))}`)
|
|
188
|
+
return ast
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ── Template Lexer ────────────────────────────────────────────────────────
|
|
192
|
+
|
|
193
|
+
export type TemplateToken =
|
|
194
|
+
| { type: 'TEXT'; value: string }
|
|
195
|
+
| { type: 'INTERPOLATION'; value: string }
|
|
196
|
+
| { type: 'TAG'; name: string; inner: string | null }
|
|
197
|
+
|
|
198
|
+
export function tokenizeTemplate(source: string): TemplateToken[] {
|
|
199
|
+
const tokens: TemplateToken[] = []
|
|
200
|
+
let cursor = 0
|
|
201
|
+
const length = source.length
|
|
202
|
+
|
|
203
|
+
while (cursor < length) {
|
|
204
|
+
const nextInterp = source.indexOf('{{', cursor)
|
|
205
|
+
const nextTag = source.indexOf('@', cursor)
|
|
206
|
+
|
|
207
|
+
let nextMatch = -1
|
|
208
|
+
let isTag = false
|
|
209
|
+
|
|
210
|
+
if (nextInterp !== -1 && nextTag !== -1) {
|
|
211
|
+
if (nextTag < nextInterp) { nextMatch = nextTag; isTag = true }
|
|
212
|
+
else { nextMatch = nextInterp }
|
|
213
|
+
} else if (nextInterp !== -1) {
|
|
214
|
+
nextMatch = nextInterp
|
|
215
|
+
} else if (nextTag !== -1) {
|
|
216
|
+
nextMatch = nextTag; isTag = true
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (nextMatch === -1) {
|
|
220
|
+
tokens.push({ type: 'TEXT', value: source.slice(cursor) })
|
|
221
|
+
break
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// If we pushed up to before `@`, push that text.
|
|
225
|
+
if (nextMatch > cursor) {
|
|
226
|
+
tokens.push({ type: 'TEXT', value: source.slice(cursor, nextMatch) })
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (isTag) {
|
|
230
|
+
cursor = nextMatch + 1
|
|
231
|
+
let tagName = ''
|
|
232
|
+
let tagLen = 0
|
|
233
|
+
const sub = source.slice(cursor)
|
|
234
|
+
|
|
235
|
+
if (sub.startsWith('else if')) { tagName = 'else if'; tagLen = 7 }
|
|
236
|
+
else if (sub.startsWith('if')) { tagName = 'if'; tagLen = 2 }
|
|
237
|
+
else if (sub.startsWith('else')) { tagName = 'else'; tagLen = 4 }
|
|
238
|
+
else if (sub.startsWith('endif')) { tagName = 'endif'; tagLen = 5 }
|
|
239
|
+
else if (sub.startsWith('for')) { tagName = 'for'; tagLen = 3 }
|
|
240
|
+
else if (sub.startsWith('endfor')) { tagName = 'endfor'; tagLen = 6 }
|
|
241
|
+
else if (sub.startsWith('const')) { tagName = 'const'; tagLen = 5 }
|
|
242
|
+
|
|
243
|
+
let isValidTag = false
|
|
244
|
+
if (tagName !== '') {
|
|
245
|
+
const nextChar = sub[tagLen]
|
|
246
|
+
// Valid boundaries: anything that is not a continuing identifier character
|
|
247
|
+
if (nextChar === undefined || !/^[a-zA-Z0-9_$]$/.test(nextChar)) {
|
|
248
|
+
isValidTag = true
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (!isValidTag) {
|
|
253
|
+
// False positive `@` (e.g. email, JSON-LD @context). Push `@` as text and resume.
|
|
254
|
+
tokens.push({ type: 'TEXT', value: '@' })
|
|
255
|
+
continue
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
cursor += tagLen
|
|
259
|
+
while (cursor < length && (source[cursor] === ' ' || source[cursor] === '\t')) cursor++
|
|
260
|
+
|
|
261
|
+
let inner: string | null = null
|
|
262
|
+
if (tagName === 'if' || tagName === 'else if' || tagName === 'for') {
|
|
263
|
+
if (source[cursor] !== '(') throw new Error(`Expected '(' after @${tagName}`)
|
|
264
|
+
cursor++
|
|
265
|
+
let depth = 1
|
|
266
|
+
let startInner = cursor
|
|
267
|
+
let inString: string | null = null
|
|
268
|
+
while (cursor < length && depth > 0) {
|
|
269
|
+
const c = source[cursor]
|
|
270
|
+
if (inString) {
|
|
271
|
+
if (c === inString) inString = null
|
|
272
|
+
} else {
|
|
273
|
+
if (c === '"' || c === "'") inString = c
|
|
274
|
+
else if (c === '(') depth++
|
|
275
|
+
else if (c === ')') depth--
|
|
276
|
+
}
|
|
277
|
+
cursor++
|
|
278
|
+
}
|
|
279
|
+
if (depth > 0) throw new Error(`Unclosed '(' in @${tagName}`)
|
|
280
|
+
inner = source.slice(startInner, cursor - 1)
|
|
281
|
+
} else if (tagName === 'const') {
|
|
282
|
+
let startInner = cursor
|
|
283
|
+
while (cursor < length && source[cursor] !== '\n') cursor++
|
|
284
|
+
inner = source.slice(startInner, cursor).trim()
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (source[cursor] === '\n') cursor++
|
|
288
|
+
else if (source[cursor] === '\r' && source[cursor + 1] === '\n') cursor += 2
|
|
289
|
+
|
|
290
|
+
tokens.push({ type: 'TAG', name: tagName, inner })
|
|
291
|
+
} else {
|
|
292
|
+
cursor = nextMatch + 2
|
|
293
|
+
let startInner = cursor
|
|
294
|
+
let inString: string | null = null
|
|
295
|
+
while (cursor < length) {
|
|
296
|
+
const c = source[cursor]
|
|
297
|
+
const next = source[cursor + 1]
|
|
298
|
+
if (inString) {
|
|
299
|
+
if (c === inString) inString = null
|
|
300
|
+
cursor++
|
|
301
|
+
} else {
|
|
302
|
+
if (c === '"' || c === "'") { inString = c; cursor++ }
|
|
303
|
+
else if (c === '}' && next === '}') break
|
|
304
|
+
else cursor++
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
if (cursor >= length) throw new Error("Unclosed interpolation")
|
|
308
|
+
const expr = source.slice(startInner, cursor).trim()
|
|
309
|
+
cursor += 2
|
|
310
|
+
tokens.push({ type: 'INTERPOLATION', value: expr })
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return tokens
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// ── Template Parser ───────────────────────────────────────────────────────
|
|
317
|
+
|
|
318
|
+
export type ASTNode =
|
|
319
|
+
| { type: 'TEXT'; content: string }
|
|
320
|
+
| { type: 'INTERPOLATION'; expression: ExprAST }
|
|
321
|
+
| { type: 'CONST'; name: string; expression: ExprAST }
|
|
322
|
+
| { type: 'IF'; condition: ExprAST; consequence: ASTNode[]; alternate: ASTNode[] | null }
|
|
323
|
+
| { type: 'FOR'; itemName: string; listExpression: ExprAST; body: ASTNode[] }
|
|
324
|
+
|
|
325
|
+
export function parseTemplate(tokens: TemplateToken[]): ASTNode[] {
|
|
326
|
+
let pos = 0
|
|
327
|
+
function parseBlock(stopTags: string[]): ASTNode[] {
|
|
328
|
+
const nodes: ASTNode[] = []
|
|
329
|
+
while (pos < tokens.length) {
|
|
330
|
+
const token = tokens[pos]
|
|
331
|
+
if (token.type === 'TAG' && stopTags.includes(token.name)) break
|
|
332
|
+
pos++
|
|
333
|
+
if (token.type === 'TEXT') {
|
|
334
|
+
nodes.push({ type: 'TEXT', content: token.value })
|
|
335
|
+
} else if (token.type === 'INTERPOLATION') {
|
|
336
|
+
nodes.push({ type: 'INTERPOLATION', expression: parseExpression(token.value) })
|
|
337
|
+
} else if (token.type === 'TAG') {
|
|
338
|
+
nodes.push(parseTagNode(token))
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
return nodes
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function parseTagNode(token: { type: 'TAG', name: string, inner: string | null }): ASTNode {
|
|
345
|
+
if (token.name === 'if') {
|
|
346
|
+
if (!token.inner) throw new Error("Missing condition for @if")
|
|
347
|
+
const condition = parseExpression(token.inner)
|
|
348
|
+
const consequence = parseBlock(['else if', 'else', 'endif'])
|
|
349
|
+
let alternate: ASTNode[] | null = null
|
|
350
|
+
if (pos < tokens.length) {
|
|
351
|
+
const stopToken = tokens[pos] as { type: 'TAG', name: string, inner: string | null }
|
|
352
|
+
if (stopToken.name === 'else if') {
|
|
353
|
+
pos++
|
|
354
|
+
stopToken.name = 'if'
|
|
355
|
+
alternate = [parseTagNode(stopToken)]
|
|
356
|
+
} else if (stopToken.name === 'else') {
|
|
357
|
+
pos++
|
|
358
|
+
alternate = parseBlock(['endif'])
|
|
359
|
+
pos++
|
|
360
|
+
} else if (stopToken.name === 'endif') {
|
|
361
|
+
pos++
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
return { type: 'IF', condition, consequence, alternate }
|
|
365
|
+
}
|
|
366
|
+
if (token.name === 'for') {
|
|
367
|
+
if (!token.inner) throw new Error("Missing params for @for")
|
|
368
|
+
const matchIndex = token.inner.indexOf(' of ')
|
|
369
|
+
if (matchIndex === -1) throw new Error(`Missing ' of ' in @for: ${token.inner}`)
|
|
370
|
+
const itemName = token.inner.slice(0, matchIndex).trim()
|
|
371
|
+
const listExpressionStr = token.inner.slice(matchIndex + 4)
|
|
372
|
+
const listExpression = parseExpression(listExpressionStr)
|
|
373
|
+
const body = parseBlock(['endfor'])
|
|
374
|
+
pos++
|
|
375
|
+
return { type: 'FOR', itemName, listExpression, body }
|
|
376
|
+
}
|
|
377
|
+
if (token.name === 'const') {
|
|
378
|
+
if (!token.inner) throw new Error("Missing params for @const")
|
|
379
|
+
const eqIndex = token.inner.indexOf('=')
|
|
380
|
+
if (eqIndex === -1) throw new Error(`Malformed @const, missing '=' in: ${token.inner}`)
|
|
381
|
+
const name = token.inner.slice(0, eqIndex).trim()
|
|
382
|
+
if (!/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(name)) {
|
|
383
|
+
throw new Error(`Malformed @const, invalid identifier: ${name}`)
|
|
384
|
+
}
|
|
385
|
+
const exprStr = token.inner.slice(eqIndex + 1).trim()
|
|
386
|
+
return { type: 'CONST', name, expression: parseExpression(exprStr) }
|
|
387
|
+
}
|
|
388
|
+
throw new Error(`Unexpected block tag: @${token.name}`)
|
|
389
|
+
}
|
|
390
|
+
return parseBlock([])
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// ── Evaluator ─────────────────────────────────────────────────────────────
|
|
394
|
+
|
|
395
|
+
export function evaluateExprAST(ast: ExprAST, context: Context, pipes: PipeRegistry = {}): any {
|
|
396
|
+
switch (ast.type) {
|
|
397
|
+
case 'Literal':
|
|
398
|
+
return ast.value
|
|
399
|
+
case 'Identifier':
|
|
400
|
+
return context[ast.name]
|
|
401
|
+
case 'Member': {
|
|
402
|
+
const obj = evaluateExprAST(ast.object, context, pipes)
|
|
403
|
+
if (obj == null) return undefined
|
|
404
|
+
const prop = ast.computed ? evaluateExprAST(ast.property as ExprAST, context, pipes) : ast.property as string
|
|
405
|
+
return obj[prop]
|
|
406
|
+
}
|
|
407
|
+
case 'Binary': {
|
|
408
|
+
const left = evaluateExprAST(ast.left, context, pipes)
|
|
409
|
+
const right = evaluateExprAST(ast.right, context, pipes)
|
|
410
|
+
switch (ast.operator) {
|
|
411
|
+
case '===': return left === right
|
|
412
|
+
case '!==': return left !== right
|
|
413
|
+
case '==': return left == right
|
|
414
|
+
case '!=': return left != right
|
|
415
|
+
case '<': return left < right
|
|
416
|
+
case '>': return left > right
|
|
417
|
+
case '<=': return left <= right
|
|
418
|
+
case '>=': return left >= right
|
|
419
|
+
default: return false
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
case 'Pipe': {
|
|
423
|
+
let base = evaluateExprAST(ast.base, context, pipes)
|
|
424
|
+
const fn = pipes[ast.name]
|
|
425
|
+
if (!fn) throw new Error(`Unknown pipe: ${ast.name}`)
|
|
426
|
+
const args = ast.args.map(a => evaluateExprAST(a, context, pipes))
|
|
427
|
+
return fn(base, ...args)
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
export function evaluateTemplate(nodes: ASTNode[], context: Context, pipes: PipeRegistry = {}): string {
|
|
433
|
+
let output = ''
|
|
434
|
+
const localContext = { ...context }
|
|
435
|
+
|
|
436
|
+
for (const node of nodes) {
|
|
437
|
+
switch (node.type) {
|
|
438
|
+
case 'TEXT':
|
|
439
|
+
output += node.content
|
|
440
|
+
break
|
|
441
|
+
case 'INTERPOLATION': {
|
|
442
|
+
const val = evaluateExprAST(node.expression, localContext, pipes)
|
|
443
|
+
output += (val == null ? '' : String(val))
|
|
444
|
+
break
|
|
445
|
+
}
|
|
446
|
+
case 'CONST':
|
|
447
|
+
if (Object.prototype.hasOwnProperty.call(localContext, node.name)) {
|
|
448
|
+
throw new Error(`TemplateError: Cannot shadow or redefine constant '${node.name}'`)
|
|
449
|
+
}
|
|
450
|
+
localContext[node.name] = evaluateExprAST(node.expression, localContext, pipes)
|
|
451
|
+
break
|
|
452
|
+
case 'IF': {
|
|
453
|
+
const condValue = evaluateExprAST(node.condition, localContext, pipes)
|
|
454
|
+
if (condValue) {
|
|
455
|
+
output += evaluateTemplate(node.consequence, localContext, pipes)
|
|
456
|
+
} else if (node.alternate) {
|
|
457
|
+
output += evaluateTemplate(node.alternate, localContext, pipes)
|
|
458
|
+
}
|
|
459
|
+
break
|
|
460
|
+
}
|
|
461
|
+
case 'FOR': {
|
|
462
|
+
const listValue = evaluateExprAST(node.listExpression, localContext, pipes)
|
|
463
|
+
if (Array.isArray(listValue)) {
|
|
464
|
+
for (const item of listValue) {
|
|
465
|
+
const loopContext = { ...localContext, [node.itemName]: item }
|
|
466
|
+
output += evaluateTemplate(node.body, loopContext, pipes)
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
break
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
return output
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
export function render(templateStr: string, context: Context, pipes: PipeRegistry = {}): string {
|
|
477
|
+
const tokens = tokenizeTemplate(templateStr)
|
|
478
|
+
const ast = parseTemplate(tokens)
|
|
479
|
+
return evaluateTemplate(ast, context, pipes)
|
|
480
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import * as fs from 'node:fs'
|
|
2
|
+
import * as path from 'node:path'
|
|
3
|
+
import { render as engineRender, Context } from './template-engine'
|
|
4
|
+
|
|
5
|
+
// Built-in pipes available to all templates (e.g. `{{ data | json }}`)
|
|
6
|
+
const defaultPipes = {
|
|
7
|
+
json: (val: any) => JSON.stringify(val, null, 2),
|
|
8
|
+
upper: (val: string) => typeof val === 'string' ? val.toUpperCase() : val,
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export class TemplateManager {
|
|
12
|
+
private userTemplateDir: string | null = null
|
|
13
|
+
private defaultTemplateDir: string
|
|
14
|
+
|
|
15
|
+
constructor() {
|
|
16
|
+
this.defaultTemplateDir = path.resolve(__dirname, 'templates')
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Called once we know the target project root (e.g., in mcp.ts `initialize`).
|
|
21
|
+
* Ensures the user's `.relay/templates` folder exists.
|
|
22
|
+
*/
|
|
23
|
+
public initialize(projectRoot: string) {
|
|
24
|
+
this.userTemplateDir = path.join(projectRoot, '.relay', 'templates')
|
|
25
|
+
if (!fs.existsSync(this.userTemplateDir)) {
|
|
26
|
+
fs.mkdirSync(this.userTemplateDir, { recursive: true })
|
|
27
|
+
console.log(`[TemplateManager] Scaffolding local template directory: ${this.userTemplateDir}`)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Loads a template by filename.
|
|
33
|
+
* If it doesn't exist in the user's `.relay/templates/`, copies the default there first.
|
|
34
|
+
*/
|
|
35
|
+
private loadTemplate(filename: string): string {
|
|
36
|
+
if (!this.userTemplateDir) {
|
|
37
|
+
throw new Error('TemplateManager not initialized. Must call initialize(projectRoot) first.')
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const userPath = path.join(this.userTemplateDir, filename)
|
|
41
|
+
|
|
42
|
+
// Lazy Eject: if user doesn't have it, copy from our defaults
|
|
43
|
+
if (!fs.existsSync(userPath)) {
|
|
44
|
+
const defaultPath = path.join(this.defaultTemplateDir, filename)
|
|
45
|
+
if (!fs.existsSync(defaultPath)) {
|
|
46
|
+
throw new Error(`Default template not found: ${defaultPath}`)
|
|
47
|
+
}
|
|
48
|
+
fs.copyFileSync(defaultPath, userPath)
|
|
49
|
+
console.log(`[TemplateManager] Ejected default template to local workspace: ${userPath}`)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return fs.readFileSync(userPath, 'utf8')
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Evaluates the template against the provided context.
|
|
57
|
+
*/
|
|
58
|
+
public render(templateFilename: string, context: Context): string {
|
|
59
|
+
const templateContent = this.loadTemplate(templateFilename)
|
|
60
|
+
|
|
61
|
+
// We add a special pipe/logic here if we need to support `@include`.
|
|
62
|
+
// For now, AST engine handles AST processing seamlessly.
|
|
63
|
+
// If the template engine is updated to support `@include` at the AST level,
|
|
64
|
+
// we would pass a resolution function. Given the MVP, we just render.
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
return engineRender(templateContent, context, defaultPipes)
|
|
68
|
+
} catch (e: any) {
|
|
69
|
+
throw new Error(`[TemplateManager] Failed to render ${templateFilename}: ${e.message}`)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Export a singleton for the MCP server to use
|
|
75
|
+
export const templateManager = new TemplateManager()
|