Template
Contract
@modularityjs/template defines the abstract TemplateEngine:
abstract class TemplateEngine {
abstract render(
name: string,
data?: Record<string, unknown>,
): Promise<string>;
}The name parameter identifies the template (e.g. 'welcome-email'). The data parameter provides variables available inside the template.
@Module({
name: 'template',
contracts: [TemplateEngine, TemplateHelpersPool],
})
class TemplateModule {}TemplateHelpersPool is a cross-engine helper registry — see Helpers below.
Drivers
Handlebars (@modularityjs/template-handlebars)
File-based Handlebars templates with compiled template caching. Templates are .hbs files loaded from a configured directory.
import { TemplateModule } from '@modularityjs/template';
import { TemplateHandlebarsModule } from '@modularityjs/template-handlebars';
const modules = [
TemplateModule,
TemplateHandlebarsModule.forRoot({
directory: './templates',
}),
];Given directory: './templates', calling render('welcome-email', data) reads ./templates/welcome-email.hbs, compiles it with Handlebars, caches the compiled template, and returns the rendered string. Subsequent calls to the same template name skip the filesystem read and compilation.
Configuration
| Option | Default | Description |
|---|---|---|
directory | (required) | Path to the directory of .hbs files |
cacheCapacity | 200 | Maximum number of compiled templates to keep in the LRU cache |
EJS (@modularityjs/template-ejs)
File-based EJS templates with LRU compiled template caching. Templates are .ejs files loaded from a configured directory.
import { TemplateModule } from '@modularityjs/template';
import { TemplateEjsModule } from '@modularityjs/template-ejs';
const modules = [
TemplateModule,
TemplateEjsModule.forRoot({
directory: './templates',
}),
];Given directory: './templates', calling render('welcome-email', data) reads ./templates/welcome-email.ejs, compiles it with EJS, caches the compiled function, and returns the rendered string.
Configuration
| Option | Default | Description |
|---|---|---|
directory | (required) | Path to the directory of .ejs files |
cacheCapacity | 200 | Maximum number of compiled templates to keep in the LRU cache |
Usage
Rendering an email
import { Inject, Injectable } from '@modularityjs/di';
import { TemplateEngine } from '@modularityjs/template';
@Injectable()
class EmailService {
constructor(
@Inject(TemplateEngine) private readonly templates: TemplateEngine,
) {}
async sendWelcome(user: { name: string; email: string }): Promise<void> {
const html = await this.templates.render('welcome-email', {
name: user.name,
});
await this.sendMail(user.email, 'Welcome!', html);
}
private async sendMail(
to: string,
subject: string,
html: string,
): Promise<void> {
// ...
}
}With templates/welcome-email.hbs:
Rendering an HTML page in a controller
import { Inject } from '@modularityjs/di';
import { Controller, Get, SetHeader } from '@modularityjs/http';
import { TemplateEngine } from '@modularityjs/template';
@Controller('/pages')
class PagesController {
constructor(
@Inject(TemplateEngine) private readonly templates: TemplateEngine,
) {}
@Get('/about')
@SetHeader('Content-Type', 'text/html')
async about() {
return this.templates.render('about', {
title: 'About Us',
year: new Date().getFullYear(),
});
}
}HTTP integration (@modularityjs/template-http)
Injecting TemplateEngine into every controller and setting Content-Type by hand works, but it puts the same three lines in every handler. template-http moves rendering into the request lifecycle:
import { Controller, Get } from '@modularityjs/http';
import { Layout, View } from '@modularityjs/template-http';
@Controller('/pages')
@Layout('layouts/main')
class PagesController {
@Get('/about')
@View('about')
async about() {
return { title: 'About Us' }; // returned data becomes the template context
}
}@View(name)— a parameter resolver registered declaratively, exactly like@Sessionor@Flag. The handler returns plain data; the resolver rendersnamewith it and sets the HTML content type.@Layout(spec)— declares the layout for a controller or a single method.ViewService— the extension point. Override the preference to change how a view name maps to a template, to inject globals into every render, or to swap rendering wholesale.
template-http depends only on the abstract http contract and TemplateEngine, so it works with any HTTP driver and any template driver.
Layout wrapping (@modularityjs/template-http-layout)
TemplateHttpModule on its own renders the view; it does not wrap it. TemplateHttpLayoutModule adds the outermost-first wrapping pass that turns @Layout('layouts/main') into an actual enclosing render. It is a separate package because layout wrapping is a policy — an API-only app rendering fragments wants the resolver without it.
htmx-aware layouts (@modularityjs/template-http-htmx)
An htmx partial request must return the fragment alone; wrapping it in the full page layout would nest a second <html> inside the target element. TemplateHttpHtmxModule bypasses the layout wrap when the request carries HX-Request: true, so the same handler serves both a full page load and an htmx swap with no branching in the controller.
const modules = [
TemplateHandlebarsModule,
TemplateHttpModule,
TemplateHttpLayoutModule,
TemplateHttpHtmxModule, // order-independent; the loader sorts topologically
];See http-htmx for the request-side helpers.
Helpers
Packages contribute reusable template helpers via TemplateHelpersPool. Each entry is an @Injectable() subclass of TemplateHelper with a name and an apply(...args) method:
import { Injectable } from '@modularityjs/di';
import { Module } from '@modularityjs/modularity';
import { TemplateHelper, TemplateHelpersPool } from '@modularityjs/template';
@Injectable()
class FormatDateHelper extends TemplateHelper {
readonly name = 'formatDate';
apply(input: unknown): string {
return new Date(String(input)).toLocaleDateString();
}
}
@Module({
name: 'my-helpers',
providers: [FormatDateHelper],
pools: [
{
pool: TemplateHelpersPool,
key: 'format-date',
useClass: FormatDateHelper,
},
],
})
class MyHelpersModule {}Templates then call the helper by name:
The Handlebars driver reads the pool in afterLoad and registers each entry via Handlebars.registerHelper. Drivers without a first-class helper registry route the pool through context injection instead — the EJS driver exposes each helper as a callable in every render's locals (<%= assetUrl('app.js') %>), with caller-supplied data winning on name collision.
The asset URL helper () is provided by @modularityjs/template-assets — wire TemplateAssetsModule to enable it.