Template::Jinja2
Template::Jinja2
Complete Jinja2 template engine for Raku, targeting compatibility with the Python reference implementation (Jinja2 3.1). Designed for HuggingFace chat templates and general-purpose templating.
Synopsis
use Template::Jinja2;
my $env = Template::Jinja2.new;
my $tmpl = $env.from-string('Hello {{ name }}!');
say $tmpl.render(name => 'World'); # Hello World!HuggingFace Chat Templates
use Template::Jinja2;
use JSON::Fast;
my $config = from-json('tokenizer_config.json'.IO.slurp);
my $chat-template = $config<chat_template>;
my $env = Template::Jinja2.new;
my $result = $env.from-string($chat-template).render(
messages => [
{ role => 'system', content => 'You are helpful.' },
{ role => 'user', content => 'Hello!' },
],
bos_token => '<s>',
add_generation_prompt => True,
);Produces byte-identical output to Python Jinja2 for ChatML, Llama 3, Mistral, Gemma 2, Zephyr, and Cohere Command A templates.
Features
Tags
All 15 Jinja2 tag types plus loop controls:
{% if %}/{% elif %}/{% else %}/{% endif %}{% for %}/{% else %}/{% endfor %}with full loop context{% set %}/{% endset %}with filter chains and namespace support{% with %}/{% endwith %}{% block %}/{% endblock %}with scoped blocks{% extends %}/{% include %}{% import %}/{% from ... import %}{% macro %}/{% endmacro %}with defaults, varargs, kwargs{% call %}/{% endcall %}with parameterized caller{% filter %}/{% endfilter %}{% do %}/{% raw %}/{# comment #}{% autoescape %}/{% endautoescape %}{% break %}/{% continue %}
Expressions
Arithmetic:
+,-,*,/,//,%,**Comparison:
==,!=,<,>,<=,>=>, chainedLogical:
and,or,notMembership:
in,not inIdentity:
is,is notTernary:
expr if test else exprString concat:
~Filters:
value | filter(args)Subscript:
items[0],items[-1],items[1:3],items[::-1]Dot access:
obj.attr,list.0Literals: strings, integers, floats, hex/octal/binary, scientific notation, lists, dicts, tuples
Python Methods
Jinja2 has no method syntax of its own: value.method() resolves the name on the underlying Python object and calls it. Templates in the wild ā HuggingFace chat templates especially ā rely on that constantly, so the Python str and dict methods are implemented with Python semantics, not those of Raku's similarly-named methods.
{%- for m in messages -%}
{#- strip() as both a guard and as output -#}
{%- if m.content.strip() -%}
<|{{ m.role }}|>{{ m.content.strip() }}
{%- endif -%}
{#- reasoning split, straight out of the GLM chat template -#}
{%- if '</think>' in m.content -%}
{%- set reasoning = m.content.split('</think>')[0].split('<think>')[-1] -%}
{%- endif -%}
{#- get() with a default, straight out of the Kimi chat template -#}
{%- set name = m.get('name') or m['role'] -%}
{%- set rc = m.get('reasoning', m.get('reasoning_content', '')) -%}
{%- endfor -%}
{#- dict iteration for tool schemas -#}
{%- for k, v in tool.items() -%}"{{ k }}": {{ v | tojson }}{%- endfor -%}String methods:
strip(chars?),lstrip(chars?),rstrip(chars?)ā no argument strips whitespace; with an argument it strips any of the given characters, so"abcHIcba".strip("abc")is"HI", not a prefix trimstartswith(prefix),endswith(suffix)ā the argument may be a tuple (or list) of candidates, any of which may match:name.startswith(('sys_', 'usr_'))replace(old, new, count?)ā replaces every occurrence unlesscountis given; an emptyoldinserts between every character, as in Pythonsplit(sep?, maxsplit?)ā with no separator, splits on runs of whitespace and drops empty fields ("a b c".split()is['a', 'b', 'c'], however many blanks separate the words); with a separator it is a literal split that keeps them ("a||b".split("|")is['a', '', 'b']).maxsplitcaps the number of splits and leaves the remainder verbatimlower(),upper()ā Unicode-aware case foldingtitle()ā Python'sstr.title(): the first letter after every non-letter is uppercased, so"it's a test"becomes"It'S A Test". Note this is deliberately not thetitlefilter, which follows Jinja2's gentler word splitting and yields"It's A Test"find(needle, start?, end?)ā the first index, or -1 when absent (never an undefined value); negativestart/endcount from the end
Dict methods:
items()ā(key, value)tuples, ready for{% for k, v in d.items() %}keys(),values()get(key),get(key, default)ā a missing key yieldsNone(falsy, renders asNone) with no default, som.get('name') or m['role']works exactly as it does in Python
Method results chain, feed filters and tests, and bind through {% set %}:
{{ msg.content.strip().split('\n')[0] }}
{{ msg.content.strip() | tojson }}
{% set parts = ref.split('|') %}{{ parts[0] }}/{{ parts[-1] }}Two deliberate divergences from CPython, both consequences of a Raku Hash having no insertion order to preserve:
items(),keys()andvalues()iterate in sorted-key order, which is what{% for k in d %},|itemsand|dictsortalready do in this engine. Output is deterministic, but a template that rebuilds JSON by iterating a dict emits its keys sorted rather than in the order they were written.{{ d.keys() }}renders as a plain list (['a', 'b']) rather than Python'sdict_keys(['a', 'b'])view repr. Iterating, indexing and filtering are unaffected.
Anything else raises a TemplateRuntimeError naming the method and the receiver's type ā 'str' object has no method 'casefold' ā rather than reaching a same-named Raku method or leaking an internal error. Calling a method on an undefined value raises UndefinedError identifying the expression that produced it.
Filters (52)
abs, attr, batch, capitalize, center, count, d/default, dictsort, e/escape, filesizeformat, first, float, forceescape, format, groupby, indent, int, items, join, last, length, list, lower, lstrip, map, max, min, pprint, random, reject, rejectattr, replace, reverse, round, rstrip, safe, select, selectattr, slice, sort, string, striptags, sum, title, tojson, trim, truncate, unique, upper, urlencode, urlize, wordcount, wordwrap, xmlattr
Tests (20+)
defined, undefined, none, boolean, integer, float, number, string, sequence, mapping, iterable, callable, sameas, eq/equalto, ne, lt/lessthan, le, gt/greaterthan, ge, even, odd, divisibleby, in, true, false, lower, upper
Template Inheritance
{# base.html #}
<html>{% block content %}default{% endblock %}</html>
{# child.html #}
{% extends "base.html" %}
{% block content %}{{ super() }} + override{% endblock %}Supports multi-level inheritance with super() chaining.
Whitespace Control
{%- trim left -%} {# trim both sides #}
{%+ no_lstrip +%} {# disable lstrip/trim_blocks #}my $env = Template::Jinja2.new(:trim-blocks, :lstrip-blocks);Custom Delimiters
# ERB-style
my $env = Template::Jinja2.new(
:block-start('<%'), :block-end('%>'),
:variable-start('<%='), :variable-end('%>'),
:comment-start('<%#'), :comment-end('%>'));
# PHP-style
my $env = Template::Jinja2.new(
:block-start('<?'), :block-end('?>'),
:variable-start('<?='), :variable-end('?>'),
:comment-start('<!--'), :comment-end('-->'));Loaders
use Template::Jinja2::Loader;
# File system
my $env = Template::Jinja2.new(
loader => FileSystemLoader.new(searchpath => 'templates/'));
# In-memory
my $env = Template::Jinja2.new(
loader => DictLoader.new(templates => {
'base.html' => '...',
'child.html' => '...',
}));Configuration
my $env = Template::Jinja2.new(
:autoescape, # Auto-escape {{ }} output
:trim-blocks, # Strip newline after block tags
:lstrip-blocks, # Strip leading whitespace before block tags
:!keep-trailing-newline, # Strip trailing newline from output
:line-statement-prefix('#'), # Line-based block syntax
:line-comment-prefix('##'), # Line-based comments
globals => { site => 'My Site' },
filters => { custom => sub ($v) { ... } },
tests => { custom => sub ($v) { ... } },
);Architecture
Template String -> [Lexer] -> Tokens -> [Parser] -> AST -> [Renderer] + Context -> OutputLexer ā Raku grammar tokenizer (default delimiters) or regex chunker (custom delimiters)
Parser ā Pratt-style expression parser with block tag dispatch
AST ā 30+ node types covering all Jinja2 constructs
Renderer ā AST walker with expression evaluation, scope chain, filter/test/method dispatch
Methods ā Python
str/dictmethod semantics forvalue.method()callsContext ā Scope stack with Undefined sentinel and Namespace support
Testing
644 tests across 24 test files, including:
Ported tests from the Python Jinja2 reference test suite
Real-world HuggingFace chat template validation
Byte-identical output verification against Python Jinja2 3.1.6
Python
str/dictmethod semantics, expectation-for-expectation against CPython
# Run all tests
prove6 -I lib t/
# Run a single test
raku -I lib t/09-huggingface.rakutestAuthor
Matt Doughty
License
Artistic-2.0