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 (raw and the html filter 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. 0 checks 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_html re-indents. Eshu is an optional dependency loaded on first use; requesting pretty without 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 as for/set binding 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:

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)