A string templating engine — {{ }} interpolation and @-prefixed tags for
logic, includes, layouts, and components. Reach for it when you want plain-text
templates instead of (or alongside) JSX views.
Unlike engines that compile a template to a function with eval /
new Function, Keel interprets templates against a small, safe expression
evaluator. No dynamic code generation, so the same templates run on Node and
on Cloudflare Workers (where eval is forbidden).
Rendering
Register a template by name, then render it with a state object:
import { templates, render } from "@shaferllc/keel/core";
templates().register("greeting", "Hello, {{ name }}!");
await render("greeting", { name: "Ada" }); // "Hello, Ada!"
render() returns a Promise<string> — hand it to a response or a view:
import { html, render } from "@shaferllc/keel/core";
return html(await render("greeting", { name: "Ada" }));
Register many at once (e.g. a Node loader reads .html files and passes them in):
templates().registerAll({
layout: await readFile("views/layout.html", "utf8"),
home: await readFile("views/home.html", "utf8"),
});
Interpolation
{{ user.name }} {{-- escaped: HTML-safe --}}
{{{ post.body }}} {{-- raw: unescaped, for trusted HTML --}}
{{-- this is a comment; it renders nothing --}}
Escaped {{ }} is the default and encodes & < > " '. Use raw {{{ }}} only for
HTML you trust. A null/undefined value renders as an empty string.
Expressions
Interpolation and tag conditions accept a practical subset of JavaScript —
enough for real templates, without eval:
{{ user.name }} {{-- property + index access --}}
{{ items[0] }}
{{ title.toUpperCase() }} {{-- method calls --}}
{{ items.join(", ") }}
{{ price * qty }} {{-- + - * / % --}}
{{ n > 3 && n < 10 }} {{-- comparisons, && || ! ?? --}}
{{ admin ? "Admin" : "User" }} {{-- ternary --}}
{{ [1, 2, 3].length }} {{-- array / object literals --}}
{{ { role: "admin" }.role }}
Not supported: assignment, arrow functions, and other statement-level JS. Keep logic in your controller and pass results in as state.
Filters
Pipe a value through a filter with |:
{{ name | upper }}
{{ name | capitalize }}
{{ items | length }}
{{ price | currency("USD") }} {{-- filters take arguments --}}
Built-in filters: upper, lower, capitalize, json, length. Register your
own on the engine:
templates().filter("currency", (v, code) =>
new Intl.NumberFormat("en-US", { style: "currency", currency: String(code) }).format(Number(v)),
);
Conditionals
@if(user.admin)
<span>Admin</span>
@elseif(user.member)
<span>Member</span>
@else
<span>Guest</span>
@end
Loops
@each iterates arrays (or the values of an object). A $loop variable exposes
positional info, and you can capture the index:
<ul>
@each(post in posts)
<li>{{ $loop.iteration }}. {{ post.title }}</li>
@end
</ul>
@each(item, i in items)
{{ i }}: {{ item }}
@end
$loop fields: index (0-based), iteration (1-based), first, last,
count, even, odd.
Partials
Pull one template into another with @include — it shares the current state:
{{-- list.html --}}
<ul>@each(item in items)@include("row")@end</ul>
{{-- row.html --}}
<li>{{ item }}</li>
@includeIf(condition, "name") includes only when the condition is truthy.
Layouts
A page declares its layout and fills the layout's @yield slots with @section:
{{-- layout.html --}}
<!doctype html>
<title>@yield("title")Keel@end</title>
<body>@yield("body")@end</body>
{{-- page.html --}}
@layout("layout")
@section("title"){{ page.title }} · Keel@end
@section("body")<h1>{{ page.title }}</h1>@end
@yield("name") … @end renders the matching section, falling back to the content
between @yield and @end when the page defines no such section.
Components
Components are reusable templates rendered with props and slots. The content
between @component and its @end becomes the main slot; @slot("name")
defines named slots. Inside the component, slots arrive as pre-rendered HTML
strings on a slots object:
{{-- card.html --}}
<div class="card">
<header>{{{ slots.header }}}</header>
<main>{{{ slots.main }}}</main>
<footer>{{ title }}</footer>
</div>
{{-- usage --}}
@component("card", { title: "Welcome" })
@slot("header")<h2>Hi</h2>@end
<p>Body content goes to the main slot.</p>
@end
Props are any expression evaluating to an object; they become the component's state (merged with globals).
Globals
Expose values or helpers to every template:
templates()
.global("appName", "Keel")
.global("asset", (path: string) => `/static/${path}`);
<title>{{ appName }}</title>
<img src="{{ asset('logo.svg') }}" />
Debugging
@dump(value) renders a <pre> of the value's JSON — handy while building a
template.
@dump(user)
Escaping & safety
- Escaped
{{ }}encodes HTML; only use raw{{{ }}}for trusted content. - The evaluator blocks access to
__proto__,constructor, andprototype, so template state can't be used to reach the prototype chain. - There's no
eval: a template can't execute arbitrary JavaScript, only the expression subset above.
API reference
templates()
templates(): TemplateEngine
Returns the default engine — register templates, globals, and filters on it.
templates().register("home", "…");
Notes: module-global and shared. Swap it with setTemplateEngine() (e.g. for
an isolated engine in a test).
render(name, state?)
render(name: string, state?: Record<string, unknown>): Promise<string>
Renders a registered template on the default engine.
await render("home", { user });
Notes: throws if name isn't registered. Async because includes, components,
and layouts compose asynchronously.
setTemplateEngine(engine)
setTemplateEngine(engine: TemplateEngine): TemplateEngine
Replaces the default engine and returns it.
Notes: the last call wins; useful to reset state between tests.
escapeHtml(value)
escapeHtml(value: unknown): string
HTML-escapes a value (& < > " '); null/undefined become "". This is what
{{ }} uses internally.
TemplateEngine
The engine class. Construct your own for isolation, or use templates().
register(name, source)
register(name: string, source: string): this
Parses and registers a template. Chainable.
Notes: parsing happens here, so a malformed template throws at registration, not at render.
registerAll(sources)
registerAll(sources: Record<string, string>): this
Registers many templates at once from a name → source map.
has(name)
has(name: string): boolean
Whether a template is registered.
global(name, value)
global(name: string, value: unknown): this
Exposes a value or function to every template as a global variable.
filter(name, fn)
filter(name: string, fn: Filter): this
Registers a {{ value | name }} filter. Filter is
(value: unknown, ...args: unknown[]) => unknown.
render(name, state?)
render(name: string, state?: Record<string, unknown>): Promise<string>
Renders a registered template. Throws for an unknown template, tag, or filter.
Interfaces & types
Filter
type Filter = (value: unknown, ...args: unknown[]) => unknown
A pipe filter: receives the piped value plus any filter(arg) arguments, returns
the transformed value.
RenderContext
interface RenderContext { sections: Record<string, string>; slots: Record<string, string> }
Internal per-render state threaded through layouts and components — you won't construct it directly.