Skip to content

Template ​

Contract ​

@modularityjs/template defines the abstract TemplateEngine:

typescript
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.

typescript
@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.

typescript
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 ​

OptionDefaultDescription
directory(required)Path to the directory of .hbs files
cacheCapacity200Maximum 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.

typescript
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 ​

OptionDefaultDescription
directory(required)Path to the directory of .ejs files
cacheCapacity200Maximum number of compiled templates to keep in the LRU cache

Usage ​

Rendering an email ​

typescript
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:

handlebars
<h1>Welcome, {{name}}!</h1>
<p>Thanks for signing up.</p>

Rendering an HTML page in a controller ​

typescript
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:

typescript
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 @Session or @Flag. The handler returns plain data; the resolver renders name with 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.

typescript
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:

typescript
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:

handlebars
<time>{{formatDate user.createdAt}}</time>

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.