Skip to content

nds-stack/bun-html

Repository files navigation

@nds-stack/bun-html

npm version Bun TypeScript License

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>'

How It Works

  1. Lexer — Tokenizes the template string into structured tokens (Text, Variable, Each, If, Unless, With, Partial, Layout)
  2. Parser — Recursive descent parser builds an AST from the token stream
  3. Compiler — Generates a JS function via new Function() for raw speed
  4. 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.

API

render(template, data, options?)

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)

compile(template, options?)

Compiles a template string into an AST array. Results are cached unless cache: false is set.

compileToFunction(ast)

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!'

compileToString(ast) / compileToFile(ast, outputPath)

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

precompile(inputDir, outputDir)

Precompiles 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)

getCacheStats() / clearPartials()

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)

BoundedCache

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 }

clearCache() / purgeTemplate(template)

import { clearCache, purgeTemplate } from '@nds-stack/bun-html'

purgeTemplate('Hello {{name}}')  // Remove single template from cache
clearCache()                      // Clear all caches (templates + partials + compiled)

adapter

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.

renderStream(template, data, options?)

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

Template Tags

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:

{{#each groups}}
  {{#each items}}
    {{../../title}} - {{../name}}: {{this}}
  {{/each}}
{{/each}}

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)

RenderOptions

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
}

Plugin Interface

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
}

Error Handling

  • Unclosed block tags ({{#each}}, {{#if}}, etc.) throw a SyntaxError
  • Mismatched close tags ({{/if}} without open) throw a SyntaxError
  • Missing variables render as empty string (no throw)
  • null/undefined values render as empty string
  • {{> partial}} without matching {{#def}} or partialsDir throws
  • Partials from file throw on missing file (from Bun.file().text())

Limitations

  • File-based partials and layouts always require partialsDir and are async-only
  • Helper arguments are not parsed from template expressions (helpers receive this context 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)

Multi-Instance / Cross-Boundary

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

Customization Guide

Disable caching

render(tpl, data, { cache: false })

Disable auto-escape

render(tpl, data, { autoescape: false })

Custom helpers

render('{{uppercase}}', { name: 'hello' }, {
  helpers: {
    uppercase(this: unknown) {
      return String(this).toUpperCase()
    },
  },
})

Async with partials

await render('{{> header}}<main>{{content}}</main>{{> footer}}', data, {
  partialsDir: './partials',
})

Layouts

await render('{{#layout "main"}}{{content}}{{/layout}}', data, {
  partialsDir: './layouts',
})
// layouts/main.html: <html><body>{{content}}</body></html>

Precompile CLI

bun-html compile ./views --out ./dist/views

Scans 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 }

{{#with}}

render('{{#with user}}<h1>{{name}}</h1>{{/with}}', { user: { name: 'Alice' } })
// → '<h1>Alice</h1>'

Comments

render('Hello{{! this is a comment }}World', {})
// → 'HelloWorld'

Inline partials

render('{{#def "item"}}<li>{{name}}</li>{{/def}}{{#each items}}{{> item}}{{/each}}', {
  items: [{ name: 'A' }, { name: 'B' }],
})
// → '<li>A</li><li>B</li>'

Plugins

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: '!' } }
  },
}

Framework Adapters

Express

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
})

Hono

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' }))
})

Comparison Table

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

Benchmarks

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 via process.memoryUsage().heapUsed delta. 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

Startup time (Combined template, ms)

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.

Real-World Example

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>

About

Zero-dependency Bun-native HTML template engine

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors