Template Markup Language - a Vue SFC-inspired, server-side template engine with a full component system for Node.js.
TML lets you write each component as a single .tml file containing <template>, <style>, and <script> blocks - just like Vue Single File Components, but rendered entirely on the server. CSS and JS are collected only from the components that actually render on a given page, then minified and returned as separate strings.
- Features
- Installation
- Quick Start
- The
.tmlFile Format - Template Syntax
- Component System
- Context API
- Head Injection
- Asset Pipeline
- XSS Protection
- API
- TypeScript Types
- Error Handling
- Testing
- Project Structure
- License
- Vue SFC-like syntax -
<template>,<style>,<script>blocks in a single.tmlfile - Component system - nested components with children (slots), includes, and layouts
- Context API -
@providefor passing data down the component tree without prop drilling - Directives -
@if/@elseif/@else,@each,@include,@component,@head,@children,@provide - Interpolation -
{{ escaped }}and{{{ raw }}}expressions with full JavaScript support - Inline JavaScript -
<% ... %>blocks for complex logic within templates - Automatic asset collection - CSS and JS from only the rendered components are collected and returned
- Head tag deduplication - identical
@headcontent from different components is deduplicated - esbuild-powered - CSS minification and JS bundling/IIFE-wrapping via esbuild (sync)
- Single function API - one
render()call returns{ html, css, js } - Asset injection -
injectAssets()helper to inject CSS/JS back into HTML - Framework-agnostic - use with any HTTP framework (Express, Fastify, Hono, etc.)
- XSS protection - all
{{ }}output is HTML-escaped by default - Path traversal protection - template paths are validated against the views directory
- Circular reference detection - render depth limit prevents infinite component recursion
- TypeScript - fully typed with exported type definitions
npm install tmlRequirements: Node.js >= 18
import { render, injectAssets } from "tml";
const { html, css, js } = render("./views", "pages/home", {
title: "Hello",
items: ["a", "b", "c"],
});
// Inject CSS and JS into the HTML
const finalHtml = injectAssets(html, {
css: css ? `<style>${css}</style>` : undefined,
js: js ? `<script>${js}</script>` : undefined,
});<template>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>{{ title }}</title>
</head>
<body>
@children
</body>
</html>
</template><template>
@head
<meta name="description" content="My app homepage">
@end
@component(layouts/main)
<main>
<h1>{{ title }}</h1>
<ul>
@each(item of items)
@if(item.active)
<li class="active">{{ item.name }} (#{{ $index }})</li>
@else
<li class="inactive">{{ item.name }}</li>
@end
@end
</ul>
</main>
@end
</template>
<style>
main { max-width: 800px; margin: 0 auto; padding: 2rem; }
.active { color: green; }
.inactive { color: gray; text-decoration: line-through; }
</style>When rendered, TML will:
- Compile and render the page template with the provided data
- Inject
@headcontent before</head>in the HTML - Collect CSS from all rendered components, minify via esbuild, and return as
css - Bundle/minify any
<script>blocks as IIFE and return asjs
import express from "express";
import { render, injectAssets } from "tml";
const app = express();
app.get("/", (req, res) => {
const { html, css, js } = render("./views", "pages/home", {
title: "My App",
});
const finalHtml = injectAssets(html, {
css: css ? `<style>${css}</style>` : undefined,
js: js ? `<script>${js}</script>` : undefined,
});
res.send(finalHtml);
});
app.listen(3000);Every .tml file is a single-file component with up to three blocks:
<template>
<!-- Required: the HTML template with directives and interpolation -->
</template>
<style>
/* Optional: CSS scoped to this component (collected at render time) */
</style>
<script>
// Optional: client-side JS (bundled as IIFE, collected at render time)
</script>Rules:
- The
<template>block is required - its contents are compiled into a render function <style>and<script>blocks are optional- Blocks can appear in any order
- Only one of each block type is supported per file
- Content outside of these blocks is ignored
TML supports two forms of interpolation inside <template>:
{{ expression }}The expression is evaluated as JavaScript, converted to a string, and HTML-escaped. This is the default and recommended form for user-facing data.
<p>Hello, {{ user.name }}</p>
<p>Total: {{ items.length * 2 }}</p>
<p>Status: {{ isActive ? "Active" : "Inactive" }}</p>Characters & < > " ' are escaped to their HTML entity equivalents.
{{{ expression }}}The expression is output without escaping. Use this only when you trust the content (e.g. pre-sanitized HTML from a CMS).
{{{ article.htmlContent }}}
{{{ '<em>Trusted HTML</em>' }}}Both {{ }} and {{{ }}} support any JavaScript expression. All template data variables are available directly:
{{ firstName + " " + lastName }}
{{ items.filter(i => i.active).length }}
{{ new Date().getFullYear() }}
{{ $context.theme?.primary || "#000" }}Directives are special lines that start with @. They control rendering logic.
Conditional rendering. The expression is evaluated as JavaScript:
@if(user && user.isAdmin)
<div class="admin-panel">
<h2>Admin Panel</h2>
</div>
@elseif(user)
<p>Welcome, {{ user.name }}</p>
@else
<p>Please log in</p>
@endIterates over an array or any iterable. A $index variable (0-based) is automatically available:
@each(item of items)
<div class="item">
<span class="index">#{{ $index + 1 }}</span>
<span>{{ item.name }}</span>
</div>
@endYou can iterate over any expression that returns an iterable:
@each(item of items.filter(i => i.visible))
<p>{{ item.name }}</p>
@endRenders another template inline:
@include(components/header)
@include(components/hero, { heading: title, subtitle: "Welcome" })Paths are relative to the views directory, without the .tml extension.
Renders a component and passes the content between @component and @end as children:
@component(components/card, { title: "My Card" })
<p>This paragraph becomes the children content.</p>
@endOutputs the children content passed by a parent @component:
<template>
<div class="wrapper">
@children
</div>
</template>Injects a value into the context, accessible by all descendant components via $context:
@provide(theme, { primary: "#3040d0", dark: "#1a1a2e" })Any nested component can read the value:
<p style="color: {{ $context.theme.primary }}">Themed text</p>Injects content into the document's <head> tag:
@head
<title>{{ title }} | My App</title>
<meta name="description" content="{{ description }}">
@endThe content is collected during render and inserted before </head> in the final HTML. If @head is used but the HTML does not contain a </head> tag, an error is thrown.
For logic that doesn't fit in a single expression, use inline JS blocks.
<% const fullName = user.firstName + " " + user.lastName %>
<p>{{ fullName }}</p><%
const total = items.reduce((sum, item) => sum + item.price, 0);
const tax = total * 0.18;
const grandTotal = total + tax;
%>
<p>Subtotal: ${{ total.toFixed(2) }}</p>
<p>Tax: ${{ tax.toFixed(2) }}</p>
<p>Total: ${{ grandTotal.toFixed(2) }}</p>@include renders a component inline, passing data through:
@include(components/badge, { text: "New" })The included component receives the parent's data merged with any additional props.
@component wraps content and passes it as children:
@component(components/card, { title: "Features" })
<ul>
<li>Fast rendering</li>
<li>Component system</li>
</ul>
@endInside the component, @children outputs the wrapped content.
Layouts are just components. A layout defines the HTML skeleton and uses @children to place page content:
<!-- layouts/main.tml -->
<template>
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>{{ title }}</title>
</head>
<body>
@children
</body>
</html>
</template>Pages wrap their content with the layout:
<!-- pages/home.tml -->
<template>
@component(layouts/main)
<main>
<h1>{{ title }}</h1>
</main>
@end
</template>Components can be nested to any depth. Each component's CSS and JS are collected independently:
@component(components/card, { title: "Outer Card" })
@component(components/card, { title: "Inner Card" })
<p>Deeply nested content</p>
@include(components/badge, { text: "Nested" })
@end
@endTML includes a render depth limit (100 levels) to detect accidental circular references.
The context API lets you pass data down the component tree without threading it through every intermediate component's props.
@provide(theme, { primary: "#3040d0", secondary: "#f0f0f0" })
@provide(currentUser, user)<div style="background: {{ $context.theme.secondary }}">
<p style="color: {{ $context.theme.primary }}">
Hello, {{ $context.currentUser.name }}
</p>
</div>Context flows downward. A @provide in a page is visible to all components rendered within that page. A @provide inside a component is only visible to that component's descendants.
The @head directive lets any component contribute to the document's <head>:
<template>
@head
<title>{{ post.title }} | Blog</title>
<meta property="og:title" content="{{ post.title }}">
@end
@component(layouts/main)
<article>{{ post.content }}</article>
@end
</template>Head tags from all rendered components are collected and injected before the </head> closing tag. Identical @head content from different components is automatically deduplicated.
Important: If @head is used but the rendered HTML does not contain a </head> tag, a TmlRenderError is thrown. This prevents silent failures in partial renders.
TML automatically handles CSS and JS assets:
- Collection - When a component is rendered, its
<style>and<script>blocks are collected - Deduplication - Each component's assets are stored by component path, so a component rendered multiple times only contributes its assets once
- CSS Minification - All collected CSS is concatenated and minified using esbuild's
transformSync - JS Bundling - Each component's JS is bundled independently as an IIFE using esbuild's
buildSync, then concatenated - Returned separately - CSS and JS are returned as separate strings in the
RenderResult, giving you full control over how to deliver them
import { render, injectAssets } from "tml";
const { html, css, js } = render("./views", "pages/home", data);
// Inline injection
const finalHtml = injectAssets(html, {
css: css ? `<style>${css}</style>` : undefined,
js: js ? `<script>${js}</script>` : undefined,
});
// Or use external URLs
const withExternalAssets = injectAssets(html, {
css: `<link rel="stylesheet" href="/assets/page.css">`,
js: `<script src="/assets/page.js"></script>`,
});All {{ expression }} output is HTML-escaped by default. The following characters are escaped:
| Character | Escaped to |
|---|---|
& |
& |
< |
< |
> |
> |
" |
" |
' |
' |
Use {{{ }}} (triple braces) only for trusted, pre-sanitized HTML content.
The main (and only) function. Renders a .tml template and returns HTML, CSS, and JS.
import { render } from "tml";
const result = render(viewsDir, viewPath, data);Parameters:
| Parameter | Type | Description |
|---|---|---|
viewsDir |
string |
Path to the views directory |
viewPath |
string |
Template path relative to viewsDir (without .tml extension) |
data |
Record<string, unknown> |
Template data (optional, defaults to {}) |
Returns: RenderResult
| Property | Type | Description |
|---|---|---|
html |
string |
Rendered HTML with @head content injected before </head> |
css |
string |
Minified CSS from all rendered components (empty string if none) |
js |
string |
Bundled+minified JS from all rendered components (empty string if none) |
Behavior:
- Synchronous - uses
esbuild.transformSyncandesbuild.buildSync - No caching - templates are read and compiled on every call
@headcontent is injected before</head>in the HTML- If
@headis used but</head>is not found, throwsTmlRenderError - CSS/JS are returned as separate strings, not injected into the HTML
- Component CSS/JS are deduplicated by component path
Injects CSS and JS assets into an HTML string. A convenience helper that eliminates manual string.replace() calls.
import { injectAssets } from "tml";
const finalHtml = injectAssets(html, {
css: `<style>${css}</style>`,
js: `<script>${js}</script>`,
});Parameters:
| Parameter | Type | Description |
|---|---|---|
html |
string |
The HTML string to inject assets into |
options |
InjectAssetsOptions |
Object with optional css and js strings |
Options:
| Property | Type | Description |
|---|---|---|
css |
string | undefined |
HTML string to inject before </head> (e.g. <style> or <link> tag) |
js |
string | undefined |
HTML string to inject before </body> (e.g. <script> tag) |
Returns: string - The HTML with assets injected.
Behavior:
cssis injected immediately before the</head>closing tagjsis injected immediately before the</body>closing tag- If
cssis provided but</head>is not found, throws anError - If
jsis provided but</body>is not found, throws anError - If neither
cssnorjsis provided, the HTML is returned as-is
All types are exported from the main entry point:
import type {
CompiledTemplate,
InjectAssetsOptions,
ParsedComponent,
RenderResult,
} from "tml";interface RenderResult {
html: string; // Rendered HTML string
css: string; // Minified CSS from all rendered components
js: string; // Bundled+minified JS from all rendered components
}interface InjectAssetsOptions {
css?: string; // HTML string to inject before </head>
js?: string; // HTML string to inject before </body>
}interface ParsedComponent {
template: string; // Content of <template> block
style: string; // Content of <style> block
script: string; // Content of <script> block
}TML provides two error classes for template issues:
Thrown during template compilation (syntax errors in directives):
import { TmlCompileError } from "tml";
try {
render("./views", "pages/broken", data);
} catch (error) {
if (error instanceof TmlCompileError) {
console.error(error.message); // "Unclosed @if block - missing @end at pages/broken:15"
console.error(error.filePath); // "pages/broken"
console.error(error.line); // 15
}
}Thrown during template rendering (runtime errors in expressions):
import { TmlRenderError } from "tml";
try {
render("./views", "pages/home", data);
} catch (error) {
if (error instanceof TmlRenderError) {
console.error(error.message); // "Cannot read properties of undefined at pages/home:0"
console.error(error.filePath); // "pages/home"
console.error(error.line); // 0
}
}Common render errors:
- Undefined variable access in expressions
Maximum render depth (100) exceeded - possible circular component referenceTemplate not found: ...Path traversal detected: ...@head directive requires a </head> tag in the document
npm test # Run all tests once
npm run test:watch # Run tests in watch modeTests use vitest and cover helpers, parser, compiler, and engine integration.
src/
index.ts # Public API exports
engine.ts # render() function, head injection, CSS/JS processing
compiler.ts # Template-to-function compiler (directives, interpolation)
parser.ts # SFC parser (extracts <template>, <style>, <script>)
helpers.ts # HTML escaping, path safety
types.ts # Shared TypeScript types and interfaces
test/
fixtures/ # Minimal .tml files for integration tests
helpers.test.ts # escapeHtml, safePath tests
parser.test.ts # SFC parser tests
compiler.test.ts # Compiler directive and interpolation tests
engine.test.ts # render() integration tests
example/
app.ts # Demo script (prints HTML/CSS/JS to stdout)
views/ # Example .tml templates