@pronghorn/fawn
Fawn
Fawn is a lightweight, TypeScript-first template engine built as an external plugin for Pronghorn. It compiles .fawn files into cached native JS functions, giving you clean tag-based syntax, layouts, includes, and pipe-style filters without pulling in EJS, Pug, or Nunjucks.
Built as a standalone package (
@pronghorn/fawn), designed to plug into Pronghorn's decorator system, no other framework required.
Why Fawn
Fawn avoids the syntax baggage of older engines while keeping the same compile-once, render-many performance model used internally by Pug and Nunjucks.
- Clean tag-based delimiters:
{{ }}for escaped output,{! !}for raw output,{~ ~}for logic. - A single universal
{~ end ~}closes any block, noendif,endfor, orendblockto remember. - Pipe-style filters (
{{ name | upper }}) alongside plain JS expressions ({{ items.length }}). - Layouts, named blocks, and slots for page composition, resolved entirely at compile time.
- Zero runtime dependencies, and
pronghornitself is only a peer dependency for types. - Templates compile once into a
Function, then every subsequent render is a direct function call.
Installation
bun add @pronghorn/fawn
Requires Bun >=1.3.0 and pronghorn >=0.1.2 as a peer dependency (used for typing the plugin only).
Quick Start
import { createApp } from 'pronghorn'
import { fawnPlugin } from '@pronghorn/fawn'
const app = createApp()
await app.register(fawnPlugin, {
viewsDirectory: 'views',
layout: 'layout'
})
app.get('/', context => {
const render = context.get<(view: string, data?: Record<string, unknown>) => Promise<Response>>('render')
return render('home', { title: 'Hello from Fawn 🦌', items: ['a', 'b'] })
})
await app.listen(4000)
views/layout.fawn:
<!DOCTYPE html>
<html>
<head>
<title>{{ title }}</title>
</head>
<body>
{~ slot "content" ~}
</body>
</html>
views/home.fawn:
{~ layout "layout" ~}
{~ block "content" ~}
<h1>{{ title | upper }}</h1>
{~ end ~}
Core Concepts
Output tags
Three delimiter pairs, each with a distinct purpose.
| Syntax | Behavior |
|---|---|
{{ expression }} |
Evaluates the expression and HTML-escapes the result |
{! expression !} |
Evaluates the expression and inserts it raw (unescaped) |
{~ tag ~} |
Logic tag: if, for, set, include, layout, block, slot, end |
<p>{{ user.name }}</p> <!-- escaped, safe for untrusted input -->
<div>{! markdownHtml !}</div> <!-- raw, only for trusted/pre-sanitized HTML -->
Conditionals
{~ if ~}, {~ else if ~}, {~ else ~}, closed by a single {~ end ~}.
{~ if user.role === "admin" ~}
<span>Admin</span>
{~ else if user.role === "editor" ~}
<span>Editor</span>
{~ else ~}
<span>Viewer</span>
{~ end ~}
Loops
{~ for item in list ~} or {~ for item, index in list ~}, also closed by {~ end ~}.
<ul>
{~ for item, i in items ~}
<li>{{ i }}: {{ item }}</li>
{~ end ~}
</ul>
Local variables
{~ set name = expression ~} declares a template-scoped variable.
{~ set total = price * quantity ~}
<p>Total: {{ total | currency }}</p>
Includes
{~ include "partial" ~} inlines another .fawn file's AST at compile time, no runtime file I/O per render.
{~ include "partials/header" ~}
<main>{{ content }}</main>
{~ include "partials/footer" ~}
Layouts, blocks, and slots
A view declares {~ layout "name" ~} and wraps its content in one or more {~ block "name" ~}...{~ end ~} sections. The layout renders those blocks via {~ slot "name" ~}.
<!-- views/layout.fawn -->
<!DOCTYPE html>
<html>
<head>
<title>{{ title }}</title>
</head>
<body>
<header>{~ slot "header" ~}</header>
<main>{~ slot "content" ~}</main>
</body>
</html>
<!-- views/dashboard.fawn -->
{~ layout "layout" ~}
{~ block "header" ~}
<h1>Dashboard</h1>
{~ end ~}
{~ block "content" ~}
{~ for widget in widgets ~}
<section>{{ widget.name }}</section>
{~ end ~}
{~ end ~}
Filters
Pipe-style filters transform a value left to right: {{ value | filterA | filterB(arg) }}.
{{ name | upper }}
{{ bio | trim | default("No bio yet") }}
{{ user | json }}
Built-in Filters
| Filter | Description |
|---|---|
upper |
Converts a string to uppercase |
lower |
Converts a string to lowercase |
capitalize |
Uppercases the first character only |
trim |
Trims leading/trailing whitespace |
json |
Serializes a value with JSON.stringify |
default(fallback) |
Returns fallback if the value is undefined, null, or "" |
Custom filters
Pass additional filters via plugin options; they merge with (and can override) the built-ins.
await app.register(fawnPlugin, {
viewsDirectory: 'views',
filters: {
currency: (value: number) => `$${value.toFixed(2)}`,
truncate: (value: string, length: number) => String(value).slice(0, length) + '…'
}
})
<p>{{ price | currency }}</p>
<p>{{ description | truncate(80) }}</p>
Plugin Options
| Option | Type | Default | Description |
|---|---|---|---|
viewsDirectory |
string |
'views' |
Directory (relative to process.cwd()) where .fawn files are resolved |
layout |
string |
undefined |
Global fallback layout used when a view has no {~ layout ~} tag |
cache |
boolean |
true |
Caches compiled template functions in memory after first render |
filters |
Record<string, FilterFn> |
{} |
Custom filters merged with the built-in filter set |
Rendering
Once registered, fawnPlugin decorates the app with render, readable in any request via context.get('render'), exactly like the built-in ejsPlugin convention.
app.get('/profile/:id', async context => {
const render = context.get<(view: string, data?: Record<string, unknown>) => Promise<Response>>('render')
const user = { id: context.params.id, name: 'Nolly' } // e.g. from a DB call
return render('profile', { user })
})
render(view, data) returns a Response with Content-Type: text/html, ready to be returned directly from a route handler.
Architecture
Fawn compiles a .fawn file through four stages, each in its own module.
| Stage | Module | Responsibility |
|---|---|---|
| Lexing | lexer.ts |
Splits raw source into text, output, raw, and tag tokens |
| Parsing | parser.ts |
Builds a nested AST, resolving the universal {~ end ~} via a stack |
| Compiling | compiler.ts |
Converts the AST into the body of a new Function, including pipe-filter rewriting |
| Rendering | engine.ts |
Resolves includes at the AST level, handles layout/block/slot composition, and caches compiled functions per view |
Includes are resolved once at compile time (their AST is spliced directly into the parent template), so a cached template never touches the filesystem again on subsequent renders.
API Reference
fawnPlugin: PluginFn - registers Fawn on a Pronghorn app via app.register(fawnPlugin, options), decorating it with render.
FawnEngine - the underlying engine class, usable standalone outside of Pronghorn if needed.
| Method | Description |
|---|---|
new FawnEngine(options?: FawnOptions) |
Creates an engine instance with its own template cache |
.render(view, data?) |
Compiles (or reuses the cached compilation of) a view and returns an HTML string |
import { FawnEngine } from '@pronghorn/fawn'
const engine = new FawnEngine({ viewsDirectory: 'views' })
const html = await engine.render('home', { title: 'Standalone usage' })
Security Notes
{{ }}always HTML-escapes its output, use it for any user-supplied or untrusted data.{! !}skips escaping entirely, only use it for content you've already sanitized (e.g. a markdown-to-HTML pipeline you control).- Templates compile to
new Function, so.fawnfiles should be treated as trusted source code, never render a.fawnfile built from raw user input.
License
WTFPL (Do What the Fuck You Want to Public License), see LICENSE for details.