NAME
Template::Stencil - a fast template engine
SYNOPSIS
use Template::Stencil;
my $stencil = Template::Stencil->new(
template_dir => 'templates',
wrapper => 'wrapper.tmpl',
);
my $html = $stencil->render('index', {
title => 'Hello',
items => [ { name => 'one' }, { name => 'two' } ],
});
# index.tmpl
# <h1>{% title | upper %}</h1>
# <ul>
# {% for item in items %}<li>{% item.name %}</li>
# {% end %}
# </ul>
DESCRIPTION
Template::Stencil compiles a small {% %} template grammar once into packed bytecode held in a single memory arena, then renders it with a threaded C interpreter that writes straight into an SV-backed buffer. The returned scalar is that buffer.
Design points:
Compile once, render from cache forever. File templates are cached with mtime revalidation; string templates are cached by content hash in a bounded LRU.
Zero per-render heap allocation and zero per-render system calls at steady state (hash-key iteration is the one documented exception - one small vector per hash loop).
One engine per process/worker; nothing is shared, so nothing is locked. Construct after fork (or just construct lazily - a thread-cloned object rebuilds its own engine automatically).
TEMPLATE SYNTAX
Everything uses one delimiter pair, {% %}, and every block closes with {% end %}. Whitespace inside the delimiters is insignificant: {%name%} and {% name %} are identical.
Output tags
{% name %} variable, HTML-escaped
{% page.header %} dotted path through hashrefs
{% page.number[0] %} array index; mixes freely: a.b[0][1].c
{% raw name %} same resolution, no escaping
{% name | upper %} filters (see below)
Auto-escaping replaces < > & " ' with entities. raw is the opt-out. An undefined or missing path renders as the empty string (croaks under strict). Paths resolve innermost-scope-first: loop variables and set bindings shadow outer scopes; the data hashref passed to render is the outermost scope. Paths may be up to 8 segments deep; traversing through a blessed reference is an error.
Filters
{% name | upper %}
{% price | default('0.00') %}
{% summary | trim | lower %}
Filters chain left to right. Auto-escaping happens once, after the last filter, unless the tag is raw or the chain ends in html (never double-escaped; a filter running after html re-escapes, because it changed the bytes). Built-ins, implemented in C:
upper uppercase (ASCII fast path, UTF-8 aware)
lower lowercase
trim strip leading/trailing whitespace
html HTML-escape now (marks the value escaped)
uri RFC 3986 percent-encoding of the component
default(x) replace undef or '' with the literal x
User filters are Perl coderefs registered on the constructor:
filters => { money => sub { sprintf '%.2f', $_[0] } }
{% price | money %}
{% s | repeat(3) %} # coderef called as ->($value, 3)
A filter argument is a single string or number literal. Unknown filter names are compile-time errors listing what is registered. A die inside a filter becomes a render error carrying the filter name and template location. User filters cost a Perl call - the escape hatch, not the fast path.
Conditionals
{% if expr %} ... {% elsif expr %} ... {% else %} ... {% end %}
{% unless expr %} ... {% end %}
The expression grammar (no arithmetic or concatenation):
operands paths, numbers (42, 3.14, -2), strings ('a' or "a",
with \' \" \\ escapes), undef
numeric == != < > <= >=
string eq ne lt gt le ge
boolean && / and, || / or, ! / not, ( ... )
other defined(path)
&& and || short-circuit and yield the deciding operand, exactly like Perl. not binds looser than comparisons, && tighter than ||. Comparison operators are typed at compile time from their spelling - == compares numerically, eq compares strings.
Truthiness follows Perl - undef, 0, '' and '0' are false - with one deliberate extension: an unblessed empty arrayref or hashref is also false, so {% if items %} guards a loop naturally.
Loops
{% for item in items %} ... {% end %}
{% for key, value in hash %} ... {% end %}
Arrays bind each element to the named variable. Hashes bind key and value, iterating in sorted key order by default so output is deterministic (sort_keys => 0 restores raw hash order). Iterating undef or an empty aggregate renders nothing. Inside a loop the implicit loop variable is in scope:
loop.index 0-based index loop.first true on first
loop.index1 1-based index loop.last true on last
loop.size total iterations loop.even parity of index1
loop.key current key (hash) loop.odd parity of index1
Loops nest arbitrarily; loop always means the innermost. Capture an outer loop before entering an inner one:
{% for item in items %}
{% set item_loop = loop %}
{% for x in item.list %}
{% item_loop.index %} / {% loop.index %}
{% end %}
{% end %}
Assignment
{% set name = expr %}
The value is any expression from the grammar above. The binding lives in the current block scope: a set inside a for body is fresh each iteration and gone after {% end %}; a set inside an if branch dies with the branch; a top-level set lasts to the end of the template. Bindings shadow data without modifying it.
Includes
{% include header.tmpl %}
{% include header %} # .tmpl appended when the name has no dot
The name is static, resolved against template_dir. The included template shares the current scope - it sees loop variables, set bindings and the root data exactly as the include site does. Includes are compiled once, linked, and revalidate independently: editing an include takes effect without recompiling its includers. Include cycles are compile-time errors naming the cycle. Absolute paths and .. segments are rejected.
Wrapper (layout)
my $stencil = Template::Stencil->new(wrapper => 'wrapper.tmpl');
# wrapper.tmpl
# <html><body>{% content %}</body></html>
With a wrapper configured, render runs the wrapper and {% content %} renders the requested template at that point - a single pass into one buffer, no intermediate string. The wrapper sees the same data. Override per render with { wrapper => 'other.tmpl' } or disable with { wrapper => undef }. A wrapper without {% content %} is refused; rendering a template containing {% content %} directly is a render error; {% content %} runs exactly once.
Comments and literal braces
{%# anything, up to the first closing percent-brace %}
Comments are stripped at compile time and may span lines; a comment may contain {% but not a close. A literal {% in output is written {%%} (the empty tag). A bare %} or } in text needs no escaping.
METHODS
new
my $stencil = Template::Stencil->new(%options);
my $stencil = Template::Stencil->new(\%options);
Builds an engine. Unknown option names croak. Options:
- template_dir => $dir
-
Base directory for file templates and includes. Must exist and be a directory. Without it, only string templates (and cwd-relative paths) work.
- wrapper => $name
-
Default layout template; see "Wrapper (layout)".
- filters => { name => sub { ... }, ... }
-
User filter registry; every value must be a coderef.
- auto_escape => 1
-
HTML-escape output tags. Set to 0 to emit everything raw (
rawand thehtmlfilter still behave as documented). Default 1. - strict => 0
-
When true, rendering an undef/missing value croaks with the full path and template location. Conditions and
defined()may still test missing values freely;default(...)can rescue a value before it reaches output. Default 0. - cache => 1
-
Cache compiled templates. Setting 0 recompiles every render. Default 1.
- cache_size => 256
-
Bound on cached string-keyed templates (LRU evicted). File templates are not bounded. Must be a positive integer.
- stat_ttl => 1
-
Seconds between mtime revalidations of cached file templates.
0checks every render, a negative value never re-checks (production: zero syscalls at steady state). Default 1. - sort_keys => 1
-
Deterministic sorted iteration for
{% for k, v in hash %}. Set 0 for raw hash order. Default 1. - chars => 0
-
Return an SvUTF8-flagged character string instead of the default wire-ready UTF-8 bytes. See "ENCODING". Default 0.
- pretty => 0
-
Post-format the rendered HTML: whitespace-only lines are removed, then Eshu's
indent_htmlre-indents. Eshu is an optional dependency loaded on first use; requestingprettywithout it is an error, never requesting it costs nothing. Roughly 1.5 microseconds per KB. Default 0.
render
my $out = $stencil->render($template, \%data);
my $out = $stencil->render($template, \%data, \%opts);
$template is either template source or a file name: an argument with no newline and no {% that resolves to a file (under template_dir, or cwd-relative, with .tmpl inference) renders the file; anything else is treated as source. Rendering equal content from string and file produces byte-identical output.
\%data is the root scope hashref (optional; missing or undef means empty). Anything else croaks. The data is never modified.
\%opts overrides per render; unknown keys croak:
wrapper => $name use this wrapper (undef disables)
strict => 0|1 override strict for this render
pretty => 0|1 override pretty for this render
The return value is the render buffer itself - a fresh scalar each call, safe to hand to a PSGI body arrayref.
Statically resolved calls (Template::Stencil::render($s, ...)) are rewritten at compile time by a call checker onto a direct C entry point; method calls use the normal XS path. Both produce identical results.
ENCODING
Encoding is the engine's job; callers never utf8::encode. By default render returns wire-ready UTF-8 bytes: UTF8-flagged values pass through, unflagged (latin-1 repped) values and template strings containing high bytes are upgraded automatically, and the uri filter percent-encodes UTF-8 bytes. length($out) is therefore the correct Content-Length. Template files are expected to be UTF-8 (or ASCII) on disk. For non-web use, chars => 1 returns a flagged character string instead.
ERRORS
All errors croak with the template name (or <string>), a line (and column for compile errors), and a message:
Template::Stencil: templates/page.tmpl:12:5: unclosed 'if' block
Template::Stencil: header.tmpl:2: undef value for 'user.name'
Errors inside an include or wrapper name the failing file, not the page that pulled it in. Unclosed blocks report the opener's position; mismatched constructs name both ends.
CONCURRENCY
One engine per interpreter is the model. Prefork servers (Hyperman) simply construct the object before or after fork - a child inherits a private copy either way. Under ithreads a cloned object lazily rebuilds its own engine, with an empty cache, from its cloned options on first use. No locks exist anywhere because nothing is shared.
C ABI
An XS module can render through the engine without a Perl frame in between. include/st_abi.h declares a function-pointer table with two entries - engine_of, which hands back the opaque engine behind a blessed Template::Stencil object, and render, which is the render path minus the XS prologue and the croak. A template error arrives through an out-parameter rather than as an exception, so a consumer can turn one into its own error response.
The table is resolved at runtime through Template::Stencil::_abi_ptr and gated on its abi_version, so there is no link-time coupling and the two distributions upgrade independently; entries are only ever appended. Reach the header with ExtUtils::Depends:
my $pkg = ExtUtils::Depends->new('My::Module', 'Template::Stencil');
Hold a reference to the object and call engine_of on each render rather than caching the engine pointer: under ithreads a cloned object drops its engine and rebuilds a fresh one lazily, so a cached handle would outlive what it points at. The lookup is a walk of the object's magic chain, which is cheap enough to mean that.
The returned SV belongs to the caller. Do not assign it to a plain lexical and return that - perl may steal the buffer; store it where it is going directly.
CAVEATS
Blessed references cannot be traversed in paths (no method calls in templates) - v0.01 renders data.
Empty unblessed aggregates are false in conditions; this is a documented extension to Perl truthiness.
Hash-loop key order is sorted bytewise by default.
Reserved words (
if unless elsif else end for set include raw content in) cannot be used in the first position of a tag or asfor/setbinding names, though they work fine as data keys.Dynamic include names, wrapper chains, arithmetic in expressions, context-aware (attribute/JS/URL) escaping, i18n and streaming render are reserved hook points for later releases.
SEE ALSO
Hyperman, Template::Stencil::PSGI, Eshu.
AUTHOR
LNATION <email@lnation.org>
BUGS
Please report any bugs or feature requests to bug-template-stencil at rt.cpan.org, or through the web interface at https://rt.cpan.org/NoAuth/ReportBug.html?Queue=Template-Stencil.
SUPPORT
You can find documentation for this module with the perldoc command.
perldoc Template::Stencil
You can also look for information at:
RT: CPAN's request tracker (report bugs here)
Search CPAN
LICENSE AND COPYRIGHT
This software is Copyright (c) 2026 by LNATION <email@lnation.org>.
This is free software, licensed under:
The Artistic License 2.0 (GPL Compatible)