@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
@@ -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()