Zero-dependency Bun-native HTML template engine with mustache-like syntax, auto-escape via Bun.escapeHTML(), async partials via Bun.file(), and custom helpers.
import { render } from '@nds-stack/bun-html'
const html = render('<h1>Hello {{name}}!</h1>', { name: 'World' })
// → '<h1>Hello World!</h1>'- Lexer — Tokenizes the template string into structured tokens (Text, Variable, Each, If, Unless, With, Partial, Layout)
- Parser — Recursive descent parser builds an AST from the token stream
- Compiler — Generates a JS function via
new Function()for raw speed - Renderer — Executes the compiled function with the data context
Auto-escaping uses Bun.escapeHTML() internally. Async partials read template files via Bun.file() with built-in caching.
| Param | Type | Description |
|---|---|---|
template |
string |
Template string with {{}} tags |
data |
Record<string, unknown> |
Context data object |
options |
RenderOptions |
Optional configuration |
Returns: string (sync) or Promise<string> (when partialsDir is set)
Compiles a template string into an AST array. Results are cached unless cache: false is set.
Compiles a template directly into a reusable JS function:
import { compile, compileToFunction } from '@nds-stack/bun-html'
const ast = compile('Hello {{name}}!')
const fn = compileToFunction(ast)
const result = fn({ name: 'World' }, undefined, Bun.escapeHTML)
// → 'Hello World!'import { compile, compileToString, compileToFile } from '@nds-stack/bun-html'
const ast = compile('Hello {{name}}!')
const code = compileToString(ast)
// → 'let $ = "" ... return $'
compileToFile(ast, 'dist/hello.js')
// → writes a ready-to-import ESM module to dist/hello.jsPrecompiles all .html files in a directory into JS modules:
import { precompile } from '@nds-stack/bun-html'
const result = precompile('./views', './dist/views')
// → { files: 12, output: './dist/views' }
// Generates: dist/views/*.js + dist/views/index.js (barrel)import { getCacheStats, clearPartials } from '@nds-stack/bun-html'
const stats = getCacheStats()
// → { template: { size, hits, misses, evictions, memoryMB }, compiled: {...} }
clearPartials() // clear only the partial cache (file-based partials)Generic LRU cache with TTL, stats, and purge:
import { BoundedCache } from '@nds-stack/bun-html'
const cache = new BoundedCache<string, any>(100, 60_000) // max 100 entries, 60s TTL
cache.set('key', 'value') // with default TTL
cache.set('key2', 'v2', 10_000) // per-entry TTL override
cache.get('key') // LRU promoted, returns undefined if expired
cache.purge() // remove all expired entries
cache.stats // { size, capacity, hits, misses, evictions, memoryBytes, memoryMB }import { clearCache, purgeTemplate } from '@nds-stack/bun-html'
purgeTemplate('Hello {{name}}') // Remove single template from cache
clearCache() // Clear all caches (templates + partials + compiled)import { adapter } from '@nds-stack/bun-html'
// Express — returns (filePath, data, callback) for app.engine()
adapter.express({ dir: './views' })
// Hono — returns middleware that adds c.var.render()
adapter.hono({ dir: './views' })See Framework Adapters for full usage.
Returns a ReadableStream<Uint8Array> for progressive HTML delivery:
import { renderStream } from '@nds-stack/bun-html'
// Bun.serve() SSR with streaming
Bun.serve({
async fetch(req) {
const stream = renderStream('<h1>{{title}}</h1>', { title: 'Hello' })
return new Response(stream, { headers: { 'Content-Type': 'text/html' } })
},
})Each top-level template node is pushed as a chunk. Works with partials, layouts, plugins, and all template features. Uses the async AST walker (not the compiled function).
| Tag | Description |
|---|---|
{{var}} |
Auto-escaped interpolation |
{{{var}}} |
Raw (unescaped) interpolation |
{{#each items}}...{{/each}} |
Loop — context: {{this}}, {{@index}}, {{@key}} |
{{#if cond}}...{{else}}...{{/if}} |
Conditional — supports expressions: {{#if age > 18}} |
{{#unless cond}}...{{/unless}} |
Inverse conditional — supports expressions |
{{#with key}}...{{/with}} |
Scoped context — changes __d to key within the block |
{{! comment }} |
Comment — stripped entirely from output |
{{#def "name"}}...{{/def}} |
Inline partial — reusable block, invoked via {{> name}} |
{{> partialName}} |
Partial (inline def first, then file via Bun.file()) |
{{#layout "name"}}...{{/layout}} |
Layout wrapper (content available as {{content}}) |
Variables support dot notation: {{user.name}}, {{address.city.zip}}.
Variable expressions: {{var}} supports full expressions — not just path resolution:
| Expression | Example |
|---|---|
| Function calls | {{name.toUpperCase()}}, {{user.getName()}} |
| Arithmetic | {{count + 1}}, {{price * (1 + tax)}} |
| Ternary | {{age >= 18 ? "adult" : "minor"}} |
| Nullish coalescing | {{name ?? "anonymous"}} |
| Unary minus | {{-balance}} |
| Array literal | {{[1, 2, 3]}} |
| Object literal | {{ {name: "Alice", age: 30} }} |
Pipe/filter syntax: Transform values through helpers with |:
render('{{name | uppercase | truncate:5}}', { name: 'hello world' }, {
helpers: {
uppercase(this: unknown) { return String(this).toUpperCase() },
truncate(this: unknown, len: number) { return String(this).slice(0, len) },
},
})
// → 'HELLO'Filters can take arguments (:arg1,arg2). Missing filters pass the value through unchanged.
Loop context: Inside {{#each}}, the following variables are available:
| Variable | Description |
|---|---|
{{this}} |
The current item |
{{@index}} |
Current index (0-based) |
{{@key}} |
Current key (index for arrays, property name for objects) |
Parent context access: Inside nested blocks, use ../ to access the parent scope. Multiple levels are supported:
One ../ per nesting level. Inside {{#each groups}} → {{../title}}. Inside nested {{#each items}} → {{../../title}}.
Expressions in conditionals: {{#if}} and {{#unless}} support full expressions:
| Operator | Example |
|---|---|
| Comparison | >, <, >=, <=, ==, != |
| Logical | &&, ||, ! |
| Parentheses | (age > 18) && (role == "admin") |
| Literals | Numbers (18), strings ("admin"), booleans (true/false), null, undefined |
| Property access | user.age, items.length |
Whitespace control: Add ~ inside mustache delimiters to strip adjacent whitespace:
| Syntax | Description |
|---|---|
{{~tag}} |
Strip whitespace before the tag |
{{tag~}} |
Strip whitespace after the tag |
{{~tag~}} |
Strip both sides |
{{{~raw~}}} |
Same for raw {{{}}} tags |
{{~#if~}}, {{~/if~}} |
Strip around block open/close tags |
render('item = {{~val~}} end', { val: 'x' })
// → 'item = xend' (whitespace around val stripped)
render('{{~#each items~}}\n {{this}}\n{{~/each~}}', { items: ['a', 'b'] })
// → 'ab' (newlines and indentation stripped)interface RenderOptions {
partialsDir?: string // Directory for partial files (triggers async mode)
cache?: boolean // Cache compiled templates (default true)
autoescape?: boolean // Auto-escape {{var}} (default true)
helpers?: Record<string, (this: unknown, ...args: unknown[]) => unknown>
plugins?: Plugin[] // Plugins with beforeRender, afterRender, helpers
}interface Plugin {
name: string
helpers?: Record<string, (this: unknown, ...args: unknown[]) => unknown>
beforeRender?: (template: string, data: Record<string, unknown>, options: RenderOptions) => { template: string; data: Record<string, unknown> }
afterRender?: (output: string, data: Record<string, unknown>) => string
}- Unclosed block tags (
{{#each}},{{#if}}, etc.) throw aSyntaxError - Mismatched close tags (
{{/if}}without open) throw aSyntaxError - Missing variables render as empty string (no throw)
null/undefinedvalues render as empty string{{> partial}}without matching{{#def}}orpartialsDirthrows- Partials from file throw on missing file (from
Bun.file().text())
- File-based partials and layouts always require
partialsDirand are async-only - Helper arguments are not parsed from template expressions (helpers receive
thiscontext only) - Both sync (compiled) and async (partialsDir) paths have full expression support — function calls, arithmetic, ternary,
??, array/object literals, and pipe filters work in all modes - Custom delimiters not supported (uses
{{}}exclusively) - No browser build (requires Bun/Node.js runtime)
Each call to render() is stateless. Caches (templateCache, partialCache) are module-level Map instances shared across all renders:
- Same process: Templates are cached once and reused across renders
- Cross-instance: No shared state — each process has its own cache
- Worker threads: Each worker has its own cache (module-level)
- Cache invalidation: Call
compile(template, { cache: false })to bypass - Cache keys: Templates are cached by raw string content — whitespace differences create separate cache entries
render(tpl, data, { cache: false })render(tpl, data, { autoescape: false })render('{{uppercase}}', { name: 'hello' }, {
helpers: {
uppercase(this: unknown) {
return String(this).toUpperCase()
},
},
})await render('{{> header}}<main>{{content}}</main>{{> footer}}', data, {
partialsDir: './partials',
})await render('{{#layout "main"}}{{content}}{{/layout}}', data, {
partialsDir: './layouts',
})
// layouts/main.html: <html><body>{{content}}</body></html>bun-html compile ./views --out ./dist/viewsScans all .html files recursively, compiles each to an ESM module, and generates an index.js barrel:
// dist/views/home.html.js
export default function(data, helpers, escapeHTML) {
// ... compiled template code
return $
}
// dist/views/index.js (auto-generated barrel)
import home_html from './home.html.js'
import partials_card_html from './partials/card.html.js'
export { home_html, partials_card_html }render('{{#with user}}<h1>{{name}}</h1>{{/with}}', { user: { name: 'Alice' } })
// → '<h1>Alice</h1>'render('Hello{{! this is a comment }}World', {})
// → 'HelloWorld'render('{{#def "item"}}<li>{{name}}</li>{{/def}}{{#each items}}{{> item}}{{/each}}', {
items: [{ name: 'A' }, { name: 'B' }],
})
// → '<li>A</li><li>B</li>'const uppercasePlugin = {
name: 'uppercase',
afterRender(output: string) { return output.toUpperCase() },
}
render('Hello {{name}}', { name: 'World' }, { plugins: [uppercasePlugin] })
// → 'HELLO WORLD'Plugins can also provide helpers and transform data before rendering:
const plugin = {
name: 'inject',
helpers: { greet() { return 'Hi' } },
beforeRender(tpl: string, data: Record<string, unknown>) {
return { template: tpl, data: { ...data, extra: '!' } }
},
}import { adapter } from '@nds-stack/bun-html'
app.engine('html', adapter.express({ dir: './views' }))
app.set('view engine', 'html')
app.get('/', (req, res) => {
res.render('index', { title: 'Hello' })
// renders ./views/index.html
})import { Hono } from 'hono'
import { adapter } from '@nds-stack/bun-html'
const app = new Hono()
app.use('*', adapter.hono({ dir: './views' }))
app.get('/', (c) => {
return c.html(c.var.render('<h1>{{title}}</h1>', { title: 'Hello' }))
})| Feature | bun-html | mustache | handlebars | ejs | nunjucks |
|---|---|---|---|---|---|
| Zero dependencies | ✅ | ❌ | ❌ | ❌ | ❌ |
| Bun-native | ✅ | ❌ | ❌ | ❌ | ❌ |
| Async partials | ✅ | ❌ | ❌ | ❌ | ✅ |
| Layouts | ✅ | ❌ | ❌ | ❌ | ✅ |
Scoped context (#with) |
✅ | ✅ | ✅ | ✅ | ✅ |
Parent context (../var) |
✅ | ✅ | ✅ | ✅ | ✅ |
| Comments | ✅ | ✅ | ❌ | ✅ | ✅ |
| Auto-escape | ✅ default | ✅ default | ✅ default | ❌ | ✅ |
| Raw output | ✅ | ✅ | ✅ | N/A | ✅ |
| Loops | ✅ | ✅ | ✅ | ✅ | ✅ |
| Conditionals | ✅ | ❌ | ✅ | ✅ | ✅ |
| Helpers | ✅ | ❌ | ✅ | ✅ | ✅ |
| Template caching | ✅ | ❌ | ✅ | ✅ | ✅ |
| Whitespace control | ✅ | ❌ | ✅ | ✅ | ✅ |
| Inline partials | ✅ | ❌ | ✅ | ❌ | ✅ |
| Plugin system | ✅ | ❌ | ❌ | ❌ | ❌ |
| Stream rendering | ✅ | ❌ | ❌ | ❌ | ❌ |
| Expressions (??, ternary, etc.) | ✅ | ❌ | ❌ | ✅ | ✅ |
| Pipe/filter syntax | ✅ | ❌ | ❌ | ❌ | ✅ |
| Precompile CLI | ✅ | ❌ | ✅ | ✅ | ✅ |
| Source maps | ✅ | ❌ | ❌ | ❌ | ❌ |
Methodology: Each library renders each template 5,000 iterations (100 warmup) using their default rendering mode. ejs and Handlebars pre-compile via
.compile(). bun-html caches compiled JS functions (new Function()) by default. Mustache and Nunjucks re-parse every call. Benchmark auto-stops if 1,000 iterations exceed 10 seconds. Memory measured viaprocess.memoryUsage().heapUseddelta. Startup time measures first (cold) vs second (warm) render on Combined template. Results in ops/sec (higher is better). Run on Bun 1.3.14, Windows 11, Intel i7-13700H.
| Template | @nds-stack/bun-html | ejs | handlebars | mustache | nunjucks |
|---|---|---|---|---|---|
| Variable | 1.2M | 1.0M | 440K | 829K | 34K |
| Loop (3 items) | 562K | 221K | 266K | 312K | 12K |
| Conditional + expression | 897K | 626K | 588K | 546K | 24K |
| Combined | 463K | 167K | 224K | 384K | 12K |
| Library | Cold | Warm |
|---|---|---|
| @nds-stack/bun-html | 0.02 | 0.00 |
| ejs | 0.20 | 0.12 |
| handlebars | 1.35 | 1.24 |
| mustache | 0.02 | 0.01 |
| nunjucks | 0.26 | 0.13 |
All libraries are on equal footing: ejs, Handlebars, and bun-html all leverage
new Function()compilation. bun-html leads on Variable (+20% vs ejs), Loop (+154% vs ejs), and Combined (+177% vs ejs). Mustache is competitive on Variable but has no compilation step. Nunjucks is a full-featured engine with autoescape + async by default — slower but more capable. Startup time: bun-html is 10× faster than ejs (0.02ms vs 0.20ms cold). Memory usage for all libraries is sub-MB at this template size.
import { render } from '@nds-stack/bun-html'
const users = [
{ name: 'Alice', role: 'admin' },
{ name: 'Bob', role: 'user' },
]
const template = `
<ul>
{{#each users}}
<li>
<span>{{name}}</span>
{{#if role == "admin"}}
<strong>ADMIN</strong>
{{else}}
<em>user</em>
{{/if}}
</li>
{{/each}}
</ul>
`
const html = render(template, { users })
// <ul><li><span>Alice</span><strong>ADMIN</strong></li><li><span>Bob</span><em>user</em></li></ul>