@wundr.io/prompt-templates 1.0.3

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/src/engine.ts ADDED
@@ -0,0 +1,616 @@
1
+ /**
2
+ * @wundr/prompt-templates - PromptTemplateEngine for dynamic prompt rendering
3
+ */
4
+
5
+ import Handlebars, { type HelperDelegate } from 'handlebars';
6
+
7
+ import { getBuiltinHelpers } from './helpers.js';
8
+ import { createLoader } from './loader.js';
9
+ import { getBuiltinMacros } from './macros.js';
10
+
11
+ import type { TemplateLoader } from './loader.js';
12
+ import type {
13
+ PromptTemplateConfig,
14
+ TemplateContext,
15
+ RenderOptions,
16
+ RenderResult,
17
+ RenderMetadata,
18
+ TemplateError,
19
+ MacroDefinition,
20
+ HelperDefinition,
21
+ HelperFunction,
22
+ EngineEvents,
23
+ EngineEventHandler,
24
+ } from './types.js';
25
+
26
+ /**
27
+ * Default render options
28
+ */
29
+ const DEFAULT_RENDER_OPTIONS: Required<RenderOptions> = {
30
+ strict: false,
31
+ delimiters: ['{{', '}}'],
32
+ escapeHtml: false,
33
+ maxDepth: 10,
34
+ timeout: 5000,
35
+ };
36
+
37
+ /**
38
+ * PromptTemplateEngine provides Jinja2-style templating using Handlebars
39
+ */
40
+ export class PromptTemplateEngine {
41
+ private readonly handlebars: typeof Handlebars;
42
+ private readonly templates: Map<string, HandlebarsTemplateDelegate> =
43
+ new Map();
44
+ private readonly templateConfigs: Map<string, PromptTemplateConfig> =
45
+ new Map();
46
+ private readonly macros: Map<string, MacroDefinition> = new Map();
47
+ private readonly customHelpers: Map<string, HelperDefinition> = new Map();
48
+ private readonly eventHandlers: Map<
49
+ keyof EngineEvents,
50
+ Set<EngineEventHandler<keyof EngineEvents>>
51
+ > = new Map();
52
+ private readonly loader: TemplateLoader;
53
+ private readonly defaultOptions: Required<RenderOptions>;
54
+
55
+ /**
56
+ * Create a new PromptTemplateEngine
57
+ *
58
+ * @param options - Default render options
59
+ * @param loaderBaseDir - Base directory for template loading
60
+ */
61
+ constructor(options?: Partial<RenderOptions>, loaderBaseDir?: string) {
62
+ this.handlebars = Handlebars.create();
63
+ this.defaultOptions = { ...DEFAULT_RENDER_OPTIONS, ...options };
64
+ this.loader = createLoader({ baseDir: loaderBaseDir });
65
+
66
+ // Disable HTML escaping by default for prompts
67
+ if (!this.defaultOptions.escapeHtml) {
68
+ this.handlebars.Utils.escapeExpression = (str: string) => str;
69
+ }
70
+
71
+ // Register built-in helpers
72
+ this.registerBuiltinHelpers();
73
+
74
+ // Register built-in macros
75
+ this.registerBuiltinMacros();
76
+ }
77
+
78
+ /**
79
+ * Render a template string with the given context
80
+ *
81
+ * @param template - Template string or template ID
82
+ * @param context - Context data for rendering
83
+ * @param options - Render options
84
+ * @returns Render result with output or error
85
+ */
86
+ render(
87
+ template: string,
88
+ context: TemplateContext = { variables: {} },
89
+ options?: Partial<RenderOptions>,
90
+ ): RenderResult {
91
+ const startTime = Date.now();
92
+ const _mergedOptions = { ...this.defaultOptions, ...options };
93
+
94
+ try {
95
+ // Check if this is a template ID
96
+ const compiledTemplate = this.getOrCompileTemplate(template);
97
+
98
+ // Prepare context with system defaults
99
+ const preparedContext = this.prepareContext(context);
100
+
101
+ // Render the template
102
+ const output = compiledTemplate(preparedContext);
103
+
104
+ const metadata: RenderMetadata = {
105
+ renderTime: Date.now() - startTime,
106
+ templateId: this.templateConfigs.has(template) ? template : undefined,
107
+ };
108
+
109
+ this.emit('template:rendered', {
110
+ templateId: metadata.templateId || 'inline',
111
+ renderTime: metadata.renderTime,
112
+ });
113
+
114
+ return {
115
+ success: true,
116
+ output,
117
+ metadata,
118
+ };
119
+ } catch (error) {
120
+ const templateError = this.createTemplateError(error, template);
121
+
122
+ this.emit('template:error', {
123
+ templateId: template,
124
+ error: templateError,
125
+ });
126
+
127
+ return {
128
+ success: false,
129
+ error: templateError,
130
+ metadata: {
131
+ renderTime: Date.now() - startTime,
132
+ },
133
+ };
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Render a template asynchronously (useful for timeout support)
139
+ *
140
+ * @param template - Template string or template ID
141
+ * @param context - Context data for rendering
142
+ * @param options - Render options
143
+ * @returns Promise resolving to render result
144
+ */
145
+ async renderAsync(
146
+ template: string,
147
+ context: TemplateContext = { variables: {} },
148
+ options?: Partial<RenderOptions>,
149
+ ): Promise<RenderResult> {
150
+ const mergedOptions = { ...this.defaultOptions, ...options };
151
+
152
+ return new Promise(resolve => {
153
+ const timeoutId = setTimeout(() => {
154
+ resolve({
155
+ success: false,
156
+ error: {
157
+ code: 'RENDER_TIMEOUT',
158
+ message: `Template rendering timed out after ${mergedOptions.timeout}ms`,
159
+ },
160
+ metadata: {
161
+ renderTime: mergedOptions.timeout,
162
+ },
163
+ });
164
+ }, mergedOptions.timeout);
165
+
166
+ try {
167
+ const result = this.render(template, context, options);
168
+ clearTimeout(timeoutId);
169
+ resolve(result);
170
+ } catch (error) {
171
+ clearTimeout(timeoutId);
172
+ resolve({
173
+ success: false,
174
+ error: this.createTemplateError(error, template),
175
+ metadata: {
176
+ renderTime: Date.now(),
177
+ },
178
+ });
179
+ }
180
+ });
181
+ }
182
+
183
+ /**
184
+ * Register a template configuration
185
+ *
186
+ * @param config - Template configuration
187
+ * @returns The engine instance for chaining
188
+ */
189
+ registerTemplate(config: PromptTemplateConfig): this {
190
+ this.templateConfigs.set(config.id, config);
191
+
192
+ // Pre-compile the template
193
+ try {
194
+ const compiled = this.handlebars.compile(config.template);
195
+ this.templates.set(config.id, compiled);
196
+ this.emit('template:loaded', { templateId: config.id });
197
+ } catch (error) {
198
+ const templateError = this.createTemplateError(error, config.id);
199
+ this.emit('template:error', {
200
+ templateId: config.id,
201
+ error: templateError,
202
+ });
203
+ throw error;
204
+ }
205
+
206
+ return this;
207
+ }
208
+
209
+ /**
210
+ * Load and register a template from file
211
+ *
212
+ * @param templatePath - Path to the template file
213
+ * @returns The engine instance for chaining
214
+ */
215
+ loadTemplate(templatePath: string): this {
216
+ const config = this.loader.loadTemplate(templatePath);
217
+ return this.registerTemplate(config);
218
+ }
219
+
220
+ /**
221
+ * Load and register a template from file asynchronously
222
+ *
223
+ * @param templatePath - Path to the template file
224
+ * @returns Promise resolving to the engine instance
225
+ */
226
+ async loadTemplateAsync(templatePath: string): Promise<this> {
227
+ const config = await this.loader.loadTemplateAsync(templatePath);
228
+ return this.registerTemplate(config);
229
+ }
230
+
231
+ /**
232
+ * Register a custom macro
233
+ *
234
+ * @param macro - Macro definition
235
+ * @returns The engine instance for chaining
236
+ */
237
+ registerMacro(macro: MacroDefinition): this {
238
+ this.macros.set(macro.name, macro);
239
+
240
+ // Register as a Handlebars partial
241
+ this.handlebars.registerPartial(macro.name, macro.template);
242
+
243
+ this.emit('macro:registered', { name: macro.name });
244
+ return this;
245
+ }
246
+
247
+ /**
248
+ * Register multiple macros
249
+ *
250
+ * @param macros - Array of macro definitions
251
+ * @returns The engine instance for chaining
252
+ */
253
+ registerMacros(macros: MacroDefinition[]): this {
254
+ for (const macro of macros) {
255
+ this.registerMacro(macro);
256
+ }
257
+ return this;
258
+ }
259
+
260
+ /**
261
+ * Register a custom helper function
262
+ *
263
+ * @param name - Helper name
264
+ * @param fn - Helper function
265
+ * @param description - Optional description
266
+ * @returns The engine instance for chaining
267
+ */
268
+ registerHelper(name: string, fn: HelperFunction, description?: string): this {
269
+ const helper: HelperDefinition = { name, fn, description };
270
+ this.customHelpers.set(name, helper);
271
+ this.handlebars.registerHelper(name, fn as HelperDelegate);
272
+ this.emit('helper:registered', { name });
273
+ return this;
274
+ }
275
+
276
+ /**
277
+ * Unregister a helper
278
+ *
279
+ * @param name - Helper name to remove
280
+ * @returns The engine instance for chaining
281
+ */
282
+ unregisterHelper(name: string): this {
283
+ this.customHelpers.delete(name);
284
+ this.handlebars.unregisterHelper(name);
285
+ return this;
286
+ }
287
+
288
+ /**
289
+ * Get a registered template configuration
290
+ *
291
+ * @param id - Template ID
292
+ * @returns Template configuration or undefined
293
+ */
294
+ getTemplate(id: string): PromptTemplateConfig | undefined {
295
+ return this.templateConfigs.get(id);
296
+ }
297
+
298
+ /**
299
+ * Get all registered template IDs
300
+ *
301
+ * @returns Array of template IDs
302
+ */
303
+ getTemplateIds(): string[] {
304
+ return Array.from(this.templateConfigs.keys());
305
+ }
306
+
307
+ /**
308
+ * Get a registered macro
309
+ *
310
+ * @param name - Macro name
311
+ * @returns Macro definition or undefined
312
+ */
313
+ getMacro(name: string): MacroDefinition | undefined {
314
+ return this.macros.get(name);
315
+ }
316
+
317
+ /**
318
+ * Get all registered macro names
319
+ *
320
+ * @returns Array of macro names
321
+ */
322
+ getMacroNames(): string[] {
323
+ return Array.from(this.macros.keys());
324
+ }
325
+
326
+ /**
327
+ * Get all registered helper names
328
+ *
329
+ * @returns Array of helper names
330
+ */
331
+ getHelperNames(): string[] {
332
+ return Array.from(this.customHelpers.keys());
333
+ }
334
+
335
+ /**
336
+ * Check if a template is registered
337
+ *
338
+ * @param id - Template ID
339
+ * @returns True if template is registered
340
+ */
341
+ hasTemplate(id: string): boolean {
342
+ return this.templateConfigs.has(id);
343
+ }
344
+
345
+ /**
346
+ * Remove a registered template
347
+ *
348
+ * @param id - Template ID
349
+ * @returns True if template was removed
350
+ */
351
+ removeTemplate(id: string): boolean {
352
+ const existed = this.templateConfigs.has(id);
353
+ this.templateConfigs.delete(id);
354
+ this.templates.delete(id);
355
+ return existed;
356
+ }
357
+
358
+ /**
359
+ * Clear all registered templates
360
+ */
361
+ clearTemplates(): void {
362
+ this.templateConfigs.clear();
363
+ this.templates.clear();
364
+ }
365
+
366
+ /**
367
+ * Add an event listener
368
+ *
369
+ * @param event - Event name
370
+ * @param handler - Event handler function
371
+ * @returns Function to remove the listener
372
+ */
373
+ on<K extends keyof EngineEvents>(
374
+ event: K,
375
+ handler: EngineEventHandler<K>,
376
+ ): () => void {
377
+ if (!this.eventHandlers.has(event)) {
378
+ this.eventHandlers.set(event, new Set());
379
+ }
380
+ const handlers = this.eventHandlers.get(event);
381
+ handlers?.add(handler as EngineEventHandler<keyof EngineEvents>);
382
+
383
+ return () => {
384
+ handlers?.delete(handler as EngineEventHandler<keyof EngineEvents>);
385
+ };
386
+ }
387
+
388
+ /**
389
+ * Get the underlying Handlebars instance (for advanced usage)
390
+ *
391
+ * @returns Handlebars instance
392
+ */
393
+ getHandlebars(): typeof Handlebars {
394
+ return this.handlebars;
395
+ }
396
+
397
+ /**
398
+ * Get the template loader instance
399
+ *
400
+ * @returns Template loader
401
+ */
402
+ getLoader(): TemplateLoader {
403
+ return this.loader;
404
+ }
405
+
406
+ /**
407
+ * Register all built-in helpers
408
+ */
409
+ private registerBuiltinHelpers(): void {
410
+ const helpers = getBuiltinHelpers();
411
+ for (const helper of helpers) {
412
+ this.handlebars.registerHelper(helper.name, helper.fn as HelperDelegate);
413
+ }
414
+
415
+ // Register additional Handlebars-style helpers
416
+ this.registerArithmeticHelpers();
417
+ this.registerLogicalHelpers();
418
+ }
419
+
420
+ /**
421
+ * Register built-in macros as partials
422
+ */
423
+ private registerBuiltinMacros(): void {
424
+ const macros = getBuiltinMacros();
425
+ for (const macro of macros) {
426
+ this.macros.set(macro.name, macro);
427
+ this.handlebars.registerPartial(macro.name, macro.template);
428
+ }
429
+ }
430
+
431
+ /**
432
+ * Register arithmetic helpers
433
+ */
434
+ private registerArithmeticHelpers(): void {
435
+ this.handlebars.registerHelper('add', (a: number, b: number) => a + b);
436
+ this.handlebars.registerHelper('subtract', (a: number, b: number) => a - b);
437
+ this.handlebars.registerHelper('multiply', (a: number, b: number) => a * b);
438
+ this.handlebars.registerHelper('divide', (a: number, b: number) =>
439
+ b !== 0 ? a / b : 0,
440
+ );
441
+ this.handlebars.registerHelper('mod', (a: number, b: number) =>
442
+ b !== 0 ? a % b : 0,
443
+ );
444
+ this.handlebars.registerHelper('abs', (a: number) => Math.abs(a));
445
+ this.handlebars.registerHelper('ceil', (a: number) => Math.ceil(a));
446
+ this.handlebars.registerHelper('floor', (a: number) => Math.floor(a));
447
+ this.handlebars.registerHelper('round', (a: number) => Math.round(a));
448
+ }
449
+
450
+ /**
451
+ * Register logical helpers
452
+ */
453
+ private registerLogicalHelpers(): void {
454
+ this.handlebars.registerHelper(
455
+ 'and',
456
+ function (this: unknown, ...args: unknown[]) {
457
+ const options = args.pop() as Handlebars.HelperOptions;
458
+ const values = args;
459
+ const result = values.every(Boolean);
460
+ if (options?.fn) {
461
+ return result
462
+ ? options.fn(this)
463
+ : options.inverse
464
+ ? options.inverse(this)
465
+ : '';
466
+ }
467
+ return result;
468
+ },
469
+ );
470
+
471
+ this.handlebars.registerHelper(
472
+ 'or',
473
+ function (this: unknown, ...args: unknown[]) {
474
+ const options = args.pop() as Handlebars.HelperOptions;
475
+ const values = args;
476
+ const result = values.some(Boolean);
477
+ if (options?.fn) {
478
+ return result
479
+ ? options.fn(this)
480
+ : options.inverse
481
+ ? options.inverse(this)
482
+ : '';
483
+ }
484
+ return result;
485
+ },
486
+ );
487
+
488
+ this.handlebars.registerHelper('not', (value: unknown) => !value);
489
+
490
+ this.handlebars.registerHelper('eq', (a: unknown, b: unknown) => a === b);
491
+ this.handlebars.registerHelper('ne', (a: unknown, b: unknown) => a !== b);
492
+ this.handlebars.registerHelper('lt', (a: number, b: number) => a < b);
493
+ this.handlebars.registerHelper('lte', (a: number, b: number) => a <= b);
494
+ this.handlebars.registerHelper('gt', (a: number, b: number) => a > b);
495
+ this.handlebars.registerHelper('gte', (a: number, b: number) => a >= b);
496
+
497
+ // Helper to create arrays inline
498
+ this.handlebars.registerHelper('array', (...args: unknown[]) => {
499
+ args.pop(); // Remove options object
500
+ return args;
501
+ });
502
+
503
+ // Helper to create objects inline
504
+ this.handlebars.registerHelper(
505
+ 'object',
506
+ (options: Handlebars.HelperOptions) => {
507
+ return options?.hash || {};
508
+ },
509
+ );
510
+
511
+ // Ternary helper
512
+ this.handlebars.registerHelper(
513
+ 'ternary',
514
+ (condition: unknown, ifTrue: unknown, ifFalse: unknown) => {
515
+ return condition ? ifTrue : ifFalse;
516
+ },
517
+ );
518
+
519
+ // Default value helper
520
+ this.handlebars.registerHelper(
521
+ 'default',
522
+ (value: unknown, defaultValue: unknown) => {
523
+ return value !== undefined && value !== null ? value : defaultValue;
524
+ },
525
+ );
526
+ }
527
+
528
+ /**
529
+ * Get or compile a template
530
+ */
531
+ private getOrCompileTemplate(template: string): HandlebarsTemplateDelegate {
532
+ // Check if it's a registered template ID
533
+ const cached = this.templates.get(template);
534
+ if (cached) {
535
+ return cached;
536
+ }
537
+
538
+ // Compile as inline template
539
+ return this.handlebars.compile(template);
540
+ }
541
+
542
+ /**
543
+ * Prepare context with defaults and system values
544
+ */
545
+ private prepareContext(context: TemplateContext): Record<string, unknown> {
546
+ const system = {
547
+ timestamp: new Date(),
548
+ ...context.system,
549
+ };
550
+
551
+ return {
552
+ ...context.variables,
553
+ system,
554
+ memory: context.memory,
555
+ tools: context.tools,
556
+ metadata: context.metadata,
557
+ };
558
+ }
559
+
560
+ /**
561
+ * Create a template error from an exception
562
+ */
563
+ private createTemplateError(
564
+ error: unknown,
565
+ templateId?: string,
566
+ ): TemplateError {
567
+ const errorMessage = error instanceof Error ? error.message : String(error);
568
+ const errorStack = error instanceof Error ? error.stack : undefined;
569
+
570
+ // Try to parse line/column from Handlebars error
571
+ const lineMatch = errorMessage.match(/line (\d+)/i);
572
+ const columnMatch = errorMessage.match(/column (\d+)/i);
573
+
574
+ return {
575
+ code: 'TEMPLATE_ERROR',
576
+ message: errorMessage,
577
+ line: lineMatch ? parseInt(lineMatch[1], 10) : undefined,
578
+ column: columnMatch ? parseInt(columnMatch[1], 10) : undefined,
579
+ templateId,
580
+ stack: errorStack,
581
+ };
582
+ }
583
+
584
+ /**
585
+ * Emit an event to all registered handlers
586
+ */
587
+ private emit<K extends keyof EngineEvents>(
588
+ event: K,
589
+ data: EngineEvents[K],
590
+ ): void {
591
+ const handlers = this.eventHandlers.get(event);
592
+ if (handlers) {
593
+ for (const handler of handlers) {
594
+ try {
595
+ (handler as EngineEventHandler<K>)(data);
596
+ } catch {
597
+ // Ignore handler errors
598
+ }
599
+ }
600
+ }
601
+ }
602
+ }
603
+
604
+ /**
605
+ * Create a new PromptTemplateEngine instance
606
+ *
607
+ * @param options - Default render options
608
+ * @param loaderBaseDir - Base directory for template loading
609
+ * @returns Configured engine instance
610
+ */
611
+ export function createEngine(
612
+ options?: Partial<RenderOptions>,
613
+ loaderBaseDir?: string,
614
+ ): PromptTemplateEngine {
615
+ return new PromptTemplateEngine(options, loaderBaseDir);
616
+ }