npm.io
0.1.0 • Published 3d ago

@pronghorn/fawn

Licence
WTFPL
Version
0.1.0
Deps
0
Size
24 kB
Vulns
0
Weekly
0

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, no endif, endfor, or endblock to 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 pronghorn itself 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 .fawn files should be treated as trusted source code, never render a .fawn file built from raw user input.

License

WTFPL (Do What the Fuck You Want to Public License), see LICENSE for details.

Keywords