Template Engine
Harpia comes with a powerful, custom-built Template Engine designed for server-side rendering (SSR). It provides a clean syntax for variables, control structures (loops, conditionals), layouts, and components, while remaining fast and extensible.
Configuration
Before rendering templates, you need to configure the engine and tell it where to find your files.
import harpia, { TemplateEngine } from "@harpiats/core";
import path from "node:path";
const app = harpia();
const engine = new TemplateEngine({
path: {
views: path.join(process.cwd(), "views"),
layouts: path.join(process.cwd(), "views/layouts"),
components: path.join(process.cwd(), "views/components")
},
fileExtension: ".html", // Defaults to .html
});
app.engine.set(engine);
Once configured, you can use res.render() in your routes:
app.get("/", async (req, res) => {
await res.render("home", { title: "Welcome to Harpia", user: { name: "Lucas" } });
});
Syntax Guide
Variables & Interpolation
By default, all variables rendered using {{ }} are automatically HTML-escaped to prevent XSS attacks.
<h1>{{ title }}</h1>
<p>Welcome, {{ user.name }}!</p>
If you need to render raw HTML from a variable, use the raw() plugin:
<!-- If content is "<strong>Bold</strong>" -->
<div>{{ raw(content) }}</div>
Setting Variables
You can define variables directly inside your templates using @set.
@set greeting = "Hello, " + user.name + "!" @endset
<p>{{ greeting }}</p>
Conditionals
Harpia supports standard @if, @elseif, @else, and @endif blocks.
@if (user.role === 'admin')
<button>Delete User</button>
@elseif (user.role === 'editor')
<button>Edit Post</button>
@else
<p>Read-only mode.</p>
@endif
Loops
You can iterate over arrays and objects using @for.
Iterating over an Array:
<ul>
@for item in items
<li>{{ item.name }}</li>
@endfor
</ul>
Iterating over an Object (Key/Value):
<ul>
@for [key, value] in myObject
<li>{{ key }}: {{ value }}</li>
@endfor
</ul>
Comments
Use ## to write server-side comments. These comments will be stripped out before the HTML is sent to the browser.
## This is a secret server comment. It won't appear in the DOM.
<p>Visible content</p>
Layouts & Blocks
Layouts allow you to define a master template and inject content into it using blocks.
1. Create a layout file (views/layouts/main.html):
<!DOCTYPE html>
<html>
<head>
<!-- Accessing a variable passed to the layout -->
<title>{{ title }}</title>
</head>
<body>
<header>My Website</header>
<main>
<!-- Yielding a block that the view will define -->
@yield('body')
</main>
</body>
</html>
2. Use the layout in your view (views/home.html):
## You can pass variables directly to the layout as the second parameter
@layout('main', { title: 'Home Page' })
@block('body')
<h1>Welcome!</h1>
<p>This is injected into the body yield.</p>
@endblock
Components & Imports
Components and imports help you keep your templates DRY (Don’t Repeat Yourself).
@component
Loads a template from the components directory. You can optionally pass a data object to it.
## Renders views/components/button.html, passing { text: "Click Me" }
@component('button', { text: "Click Me" })
@import
Similar to components, but loads a file relative to the current file’s directory. Useful for splitting large views.
@import('partials/header')
Minification
You can minify the final HTML output directly from the response to save bandwidth.
app.get("/", async (req, res) => {
// Renders and minifies the HTML before sending
await res.render("home").minify("html");
});
Advanced Rendering
Sometimes you might need more control over how templates are processed, especially in larger applications.
Generating HTML as a String
If you just want to get the final compiled HTML string without sending it as an HTTP response (for example, to send an email, save to a database, or pass to another function), you can use the engine.generate() method directly.
app.post("/send-email", async (req, res) => {
// Gets the template engine instance
const engine = app.engine.get();
// Compiles the template into a raw HTML string
const htmlContent = await engine.generate("emails/welcome", { name: "Lucas" });
// Now you can pass it to your mailer, etc.
await sendMail(htmlContent);
res.send("Email sent!");
});
Module Scoping (.module)
If your project is structured into multiple decoupled modules or domains (like users, products, orders), and each has its own views folder, you can isolate template rendering using .module().
When configured with useModules: true in the Template Engine, Harpia replaces the ** pattern in your paths with the module name.
// Configure the engine to use modules
const engine = new TemplateEngine({
path: {
views: path.join(process.cwd(), "modules/**/views"), // The ** acts as a wildcard
},
useModules: true
});
app.get("/users/profile", async (req, res) => {
// Harpia will look for: modules/users/views/profile.html
await res.module("users").render("profile", { user: req.user });
});
Custom Plugins
You can register custom JavaScript functions that can be called directly from your templates.
// Register a custom plugin
engine.registerPlugin("uppercase", (str) => str.toUpperCase());
engine.registerPlugin("trim", (str) => str.trim());
<!-- Usage in template -->
<p>{{ uppercase(user.name) }}</p>
Nesting Plugins
Harpia’s template engine is smart enough to evaluate plugins from the inside out, meaning you can nest multiple plugins together seamlessly:
<!-- Trims the string first, then uppercases it -->
<p>{{ uppercase(trim(user.name)) }}</p>
Shield Integration
If you initialize app.shield() in your application, Harpia automatically registers a generateNonce() plugin in the Template Engine. This allows you to securely add inline scripts and styles when using Content Security Policies.
<script nonce="{{ generateNonce() }}">
console.log("Secure inline script");
</script>
Using Third-Party Engines
While Harpia comes with a powerful built-in template engine, you are not forced to use it. You can easily plug in any other engine, such as Pug, Handlebars, or EJS, by creating a wrapper class that implements the Engine interface.
Here is an example of how you could integrate Pug:
import harpia from "@harpiats/core";
import pug from "pug";
import type { Engine, RenderPromise, Application } from "@harpiats/core";
class PugEngine implements Engine {
public configure(app: Application): void {
app.engine.set(this);
}
public render(view: string, data: Record<string, any> = {}): RenderPromise {
// 1. Create the async execution block
const execute = async () => {
// Use pug to compile the file
return pug.renderFile(`./views/${view}.pug`, data);
};
// 2. Cast the promise to RenderPromise
const promise = execute() as RenderPromise;
// 3. Attach a fallback minify method (required by the interface)
promise.minify = () => promise;
return promise;
}
}
const app = harpia();
new PugEngine().configure(app);
// Now res.render() will use Pug!
app.get("/", async (req, res) => {
await res.render("home", { title: "Hello Pug" });
});