NAME
Template::Stencil - fast C-core template engine for PSGI and Hyperman
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 in v0.01):
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.
CAVEATS
Blessed references cannot be traversed in paths (no method calls in templates) - v0.01 renders data, not objects.
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)