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.