Forma Template Engine
A lightweight, Handlebars-inspired template engine for building dynamic HTML pages.
Overview
Forma is Strada's built-in template rendering library. It supports variable substitution, loops, conditionals, nested object access, and layout composition. Forma is used by the Cannoli web framework for rendering dynamic pages.
Key Features
- Variable substitution with
{{variable}}syntax - Nested object access:
{{user.address.city}} - Loops with
{{#each}}and{{#range}} - Conditionals with
{{#if}},{{#unless}}, and comparison helpers - String helpers:
upper,lower,capitalize,truncate - Array helpers:
length,first,last,join - Number formatting:
commas,format_number,plural - Pipe syntax with filters:
{{var | default "value"}} - JSON output:
{{json object}} - Comments:
{{!-- comment --}} - Context switching with
{{#with}} - Compiled-template caching for performance (parse once, render many)
- Layout/partial support
- HTML escaping for XSS protection
Getting Started
use lib "lib";
use Forma;
# Initialize with template directory
Forma::init("./templates");
# Create template data
my hash %vars = ();
$vars{"title"} = "Welcome";
$vars{"username"} = "Alice";
# Render a template file
my str $html = Forma::render("page.html", \%vars);
say($html);
Template Syntax
Variables
Use double curly braces to output variables:
<!-- Template -->
<h1>Hello, {{username}}!</h1>
<p>You have {{message_count}} unread messages.</p>
Nested Object Access
Access nested properties using dot notation:
<!-- Template -->
<div class="profile">
<h2>{{user.name}}</h2>
<p>{{user.address.city}}, {{user.address.country}}</p>
<p>Email: {{user.contact.email}}</p>
</div>
# Strada code
my hash %vars = ();
$vars{"user"} = {
"name" => "Alice",
"address" => {
"city" => "Portland",
"country" => "USA"
},
"contact" => {
"email" => "alice@example.com"
}
};
Loops with {{#each}}
Loop over arrays with the {{#each}} block. Hash fields are accessible directly:
<!-- Template -->
<ul>
{{#each users}}
<li>{{name}} - {{email}}</li>
{{/each}}
</ul>
Use a named variable for clearer templates:
<!-- Template -->
<ul>
{{#each user in users}}
<li>{{user.name}} - {{user.email}}</li>
{{/each}}
</ul>
Loop Metadata
Access loop metadata with special @ variables:
<!-- Template -->
<table>
{{#each item in items}}
<tr class="{{#if @first}}first-row{{/if}} {{#if @last}}last-row{{/if}}">
<td>{{@index}}</td>
<td>{{item.name}}</td>
</tr>
{{/each}}
</table>
| Variable | Description |
|---|---|
{{@index}} | Current iteration index (0-based) |
{{@first}} | True (1) on first iteration |
{{@last}} | True (1) on last iteration |
Conditionals with {{#if}}
Show or hide content based on a condition:
<!-- Template -->
{{#if logged_in}}
<p>Welcome back, {{username}}!</p>
<a href="/logout">Logout</a>
{{else}}
<p>Please log in to continue.</p>
<a href="/login">Login</a>
{{/if}}
Truthiness
Values are considered "truthy" if they are:
- Non-empty strings (except "0")
- Non-zero numbers
- Non-empty arrays
- Any hash reference
Inverse Conditionals with {{#unless}}
Show content when a condition is false:
<!-- Template -->
{{#unless has_items}}
<p>Your cart is empty.</p>
{{/unless}}
{{#unless logged_in}}
<a href="/login">Login</a>
{{/unless}}
Context Switching with {{#with}}
Change the current scope to a nested object:
<!-- Template -->
{{#with user}}
<div class="user-card">
<h3>{{name}}</h3>
<p>{{email}}</p>
{{#with address}}
<p>{{city}}, {{country}}</p>
{{/with}}
</div>
{{/with}}
Variable Assignment with {{#set}}
Create or assign variables within templates:
<!-- Template -->
{{#set page_title = "Dashboard"}}
{{#set full_name = user.first_name}}
<title>{{page_title}}</title>
<h1>Welcome, {{full_name}}!</h1>
Range Loops with {{#range}}
Iterate over a numeric range:
<!-- Generate page numbers -->
{{#range 1 5}}
<a href="/page/{{.}}">Page {{.}}</a>
{{/range}}
<!-- Output: Page 1, Page 2, Page 3, Page 4, Page 5 -->
Range loops support the same metadata variables as {{#each}}:
{{#range 1 3}}
<span{{#if @first}} class="first"{{/if}}>{{.}}</span>
{{/range}}
Comparison Helpers
Use comparison helpers for more complex conditionals:
<!-- Equal -->
{{#if_eq status "active"}}
<span class="badge-green">Active</span>
{{else}}
<span class="badge-gray">Inactive</span>
{{/if_eq}}
<!-- Not equal -->
{{#if_ne role "admin"}}
<p>Regular user</p>
{{/if_ne}}
<!-- Numeric comparisons -->
{{#if_gt score 80}}<span>Excellent!</span>{{/if_gt}}
{{#if_lt items_count 5}}<span>Low stock</span>{{/if_lt}}
{{#if_ge age 18}}<span>Adult</span>{{/if_ge}}
{{#if_le price 100}}<span>Budget-friendly</span>{{/if_le}}
| Helper | Comparison |
|---|---|
{{#if_eq var "value"}} | Equal (string comparison) |
{{#if_ne var "value"}} | Not equal |
{{#if_gt var value}} | Greater than (numeric) |
{{#if_lt var value}} | Less than (numeric) |
{{#if_ge var value}} | Greater than or equal |
{{#if_le var value}} | Less than or equal |
String Helpers
Transform strings with built-in helpers:
<!-- Convert to uppercase -->
{{upper name}} <!-- "alice" -> "ALICE" -->
<!-- Convert to lowercase -->
{{lower title}} <!-- "HELLO" -> "hello" -->
<!-- Capitalize first letter -->
{{capitalize name}} <!-- "alice" -> "Alice" -->
<!-- Truncate with ellipsis -->
{{truncate description 50}} <!-- Long text... -->
Array Helpers
Work with arrays in templates:
<!-- Get array/string length -->
You have {{length items}} items
<!-- Get first/last element -->
First: {{first items}}
Last: {{last items}}
<!-- Join array elements -->
Tags: {{join tags ", "}} <!-- "apple, banana, cherry" -->
Number Formatting
Format numbers for display:
<!-- Add thousands separators -->
Price: ${{commas price}} <!-- 1234567 -> "1,234,567" -->
<!-- Format with decimal places -->
{{format_number pi 2}} <!-- 3.14159 -> "3.14" -->
<!-- Pluralize based on count -->
{{plural count "item" "items"}} <!-- "1 item" or "5 items" -->
Pipe Syntax with Filters
Use pipe syntax to apply filters to values:
<!-- Default value for missing variables -->
{{username | default "Guest"}}
<!-- Transform with filters -->
{{name | upper}}
{{email | lower}}
{{title | capitalize}}
{{user_content | escape}}
| Filter | Description |
|---|---|
default "value" | Use value if variable is empty/missing |
upper | Convert to uppercase |
lower | Convert to lowercase |
capitalize | Capitalize first letter |
escape | HTML-escape the value |
JSON Output
Output a variable as JSON (useful for JavaScript integration):
<script>
const userData = {{json user}};
const items = {{json items}};
</script>
Comments
Add comments that won't appear in the output:
{{!-- This comment will not be rendered --}}
<div>
{{!-- TODO: Add user avatar here --}}
<h2>{{username}}</h2>
</div>
Debugging with {{dump}}
Output a variable's contents for debugging:
<!-- Template -->
<pre>{{dump user}}</pre>
API Reference
Forma::init($dir)
Initialize the template system with a directory path.
Forma::init("./templates");
Forma::render($name, $vars)
Render a template file with variables. This is the cached fast path: the template is compiled into a reusable form on first use and that compiled form is reused on every subsequent render with the same name (see Compiled-template caching).
my str $html = Forma::render("page.html", \%vars);
Forma::render_string($template, $vars)
Render a template string directly. Unlike render(), this has no
name to key a cache on, so it compiles fresh on every call — prefer
render($name) on hot paths.
my str $html = Forma::render_string("Hello, {{name}}!", \%vars);
Forma::render_safe($name, $vars)
Render with automatic HTML escaping for all variables. Use {{{var}}} for unescaped output.
# User input is automatically escaped
my str $html = Forma::render_safe("comment.html", \%vars);
Forma::render_with_layout($template, $layout, $vars)
Render a template within a layout. The layout should contain {{content}}.
# layout.html contains: <html><body>{{content}}</body></html>
my str $html = Forma::render_with_layout("page.html", "layout.html", \%vars);
Forma::include($name, $vars)
Include and render a partial template.
my str $sidebar = Forma::include("sidebar.html", \%vars);
Forma::set_cache($enabled)
Enable or disable caching (on by default). When enabled, render($name)
compiles each template once and reuses the compiled form on every later render
(see Compiled-template caching). Disable it in
development so edits to template files are picked up without a restart —
each render then re-reads and re-compiles.
Forma::set_cache(0); # Disable for development (re-read + re-compile every render)
Forma::set_cache(1); # Enable for production (default)
Forma::clear_cache()
Clear both the raw-template-text cache and the compiled-template cache. Call this after changing template files at runtime to force a recompile on next render.
Forma::clear_cache();
Forma::escape_html($string)
Escape HTML special characters in a string.
my str $safe = Forma::escape_html("<script>alert('xss')</script>");
# Returns: <script>alert('xss')</script>
Custom Helpers (Functional Macros)
Register your own helper functions that can be called from templates. This allows you to extend Forma with domain-specific functionality.
Forma::register_helper($name, $handler)
Register a custom helper function.
# Define a helper function
# Arguments: $args (array ref), $vars (template variables)
# Returns: string result
func double_helper(scalar $args, scalar $vars) str {
my array @a = @{$args};
if (scalar(@a) == 0) {
return "0";
}
my int $n = @a[0] + 0;
return "" . ($n * 2);
}
# Register it
Forma::register_helper("double", \&double_helper);
# Use in templates
# {{double count}} -> doubles the value of count
# {{double 21}} -> outputs "42"
Using Custom Helpers in Templates
Custom helpers can be used in two ways:
<!-- As a direct helper -->
{{double count}}
{{greet name "Hello"}}
<!-- As a pipe filter -->
{{count | double}}
{{name | wrap "strong"}}
Example: Custom Helpers
# Greeting helper with optional salutation
func greet_helper(scalar $args, scalar $vars) str {
my array @a = @{$args};
my str $name = scalar(@a) > 0 ? "" . @a[0] : "World";
my str $greeting = scalar(@a) > 1 ? "" . @a[1] : "Hello";
return $greeting . ", " . $name . "!";
}
Forma::register_helper("greet", \&greet_helper);
# Template usage:
# {{greet name}} -> "Hello, Alice!"
# {{greet name "Hi"}} -> "Hi, Alice!"
# {{greet "Bob" "Hey"}} -> "Hey, Bob!"
Example: Custom Filter for Pipe Syntax
# Wrap text in HTML tags
func wrap_filter(scalar $args, scalar $vars) str {
my array @a = @{$args};
my str $val = scalar(@a) > 0 ? "" . @a[0] : "";
my str $tag = scalar(@a) > 1 ? "" . @a[1] : "span";
return "<" . $tag . ">" . $val . "</" . $tag . ">";
}
Forma::register_helper("wrap", \&wrap_filter);
# Template usage with pipe syntax:
# {{name | wrap "strong"}} -> "<strong>Alice</strong>"
# {{title | wrap "h1"}} -> "<h1>Page Title</h1>"
Forma::unregister_helper($name)
Remove a registered helper.
Forma::unregister_helper("double");
Forma::has_helper($name)
Check if a helper is registered.
if (Forma::has_helper("greet")) {
say("greet helper is available");
}
Forma::list_helpers()
Get all registered helper names.
my array @helpers = Forma::list_helpers();
say("Registered: " . join(", ", @helpers));
Forma::clear_helpers()
Remove all custom helpers.
Forma::clear_helpers();
Example: Complete Web Page
layout.html
<!DOCTYPE html>
<html>
<head>
<title>{{title}} - My Site</title>
</head>
<body>
<nav>
{{#if logged_in}}
<span>Welcome, {{username}}</span>
<a href="/logout">Logout</a>
{{else}}
<a href="/login">Login</a>
{{/if}}
</nav>
<main>
{{content}}
</main>
</body>
</html>
products.html
<h1>Products</h1>
{{#if has_products}}
<div class="product-grid">
{{#each product in products}}
<div class="product {{#if @first}}featured{{/if}}">
<h2>{{product.name}}</h2>
<p class="price">${{product.price}}</p>
{{#if product.in_stock}}
<button>Add to Cart</button>
{{else}}
<span class="out-of-stock">Out of Stock</span>
{{/if}}
</div>
{{/each}}
</div>
{{else}}
<p>No products available.</p>
{{/if}}
Strada Code
use lib "lib";
use Forma;
Forma::init("./templates");
my hash %vars = ();
$vars{"title"} = "Products";
$vars{"logged_in"} = 1;
$vars{"username"} = "Alice";
$vars{"products"} = [
{ "name" => "Widget", "price" => "19.99", "in_stock" => 1 },
{ "name" => "Gadget", "price" => "29.99", "in_stock" => 0 },
{ "name" => "Gizmo", "price" => "39.99", "in_stock" => 1 }
];
$vars{"has_products"} = 1;
my str $html = Forma::render_with_layout("products.html", "layout.html", \%vars);
say($html);
Using with Cannoli
Forma integrates seamlessly with the Cannoli web framework:
use lib "lib";
use cannoli;
use Forma;
Forma::init("./templates");
func handle_home(scalar $c) hash {
my hash %vars = ();
$vars{"title"} = "Home";
$vars{"message"} = "Welcome to my site!";
my str $html = Forma::render_with_layout("home.html", "layout.html", \%vars);
$c->status(200);
$c->content_type("text/html");
$c->write_body($html);
return $c->build_response();
}
Additional Built-in Helpers
Math Helpers
Perform arithmetic operations in templates:
<!-- Basic arithmetic -->
{{add count 10}} <!-- count + 10 -->
{{subtract total 5}} <!-- total - 5 -->
{{multiply price quantity}}<!-- price * quantity -->
{{divide total count}} <!-- total / count -->
{{mod index 2}} <!-- index % 2 (modulo) -->
<!-- Rounding -->
{{round price}} <!-- Round to nearest integer -->
{{round price 2}} <!-- Round to 2 decimal places -->
Additional String Helpers
More string manipulation tools:
<!-- Replace text -->
{{replace text "old" "new"}} <!-- Replace all occurrences -->
<!-- String checks -->
{{#if (contains name "admin")}}
<span class="admin-badge">Admin</span>
{{/if}}
{{#if (starts_with filename "temp_")}}...{{/if}}
{{#if (ends_with filename ".txt")}}...{{/if}}
<!-- Padding -->
{{pad_left id 5 "0"}} <!-- "42" -> "00042" -->
{{pad_right name 10 "."}} <!-- "Bob" -> "Bob......." -->
<!-- Repeat string -->
{{repeat "=" 20}} <!-- "====================" -->
{{repeat "*" count}} <!-- Repeat count times -->
<!-- Strip HTML tags -->
{{strip_tags html_content}}<!-- Remove all HTML tags -->
URL Encoding
Encode and decode URL components:
<!-- Encode for use in URLs -->
<a href="/search?q={{url_encode query}}">Search</a>
<!-- Decode URL-encoded strings -->
{{url_decode encoded_text}}
Date and Time Helpers
Format dates and display relative time:
<!-- Format timestamps -->
{{date_format timestamp "%Y-%m-%d"}} <!-- 2026-01-27 -->
{{date_format timestamp "%B %d, %Y"}} <!-- January 27, 2026 -->
{{date_format timestamp "%H:%M:%S"}} <!-- 14:30:45 -->
{{date_format timestamp "%A"}} <!-- Tuesday -->
<!-- Relative time (time ago) -->
{{time_ago created_at}} <!-- "5 minutes ago", "2 hours ago", "3 days ago" -->
| Format Code | Description | Example |
|---|---|---|
%Y | 4-digit year | 2026 |
%m | Month (01-12) | 01 |
%d | Day (01-31) | 27 |
%H | Hour (00-23) | 14 |
%M | Minute (00-59) | 30 |
%S | Second (00-59) | 45 |
%B | Full month name | January |
%b | Abbreviated month | Jan |
%A | Full weekday name | Tuesday |
%a | Abbreviated weekday | Tue |
Currency and Percentage Formatting
<!-- Currency formatting -->
{{currency price}} <!-- "$1,234.56" (default USD) -->
{{currency price "EUR"}} <!-- "€1,234.56" -->
{{currency price "GBP"}} <!-- "£1,234.56" -->
{{currency price "JPY"}} <!-- "¥1,235" (no decimals) -->
<!-- Percentage formatting -->
{{percent ratio}} <!-- 0.75 -> "75%" -->
{{percent ratio 1}} <!-- 0.756 -> "75.6%" -->
Inline Conditional (iif)
Ternary-style conditionals in a single expression:
<!-- value if true : value if false -->
{{iif is_active "Active" "Inactive"}}
{{iif count "Has items" "Empty"}}
<!-- Use in attributes -->
<button class="btn {{iif primary 'btn-primary' 'btn-secondary'}}">
{{label}}
</button>
Complete Helper Reference
| Category | Helper | Usage |
|---|---|---|
| String | upper | {{upper name}} |
| lower | {{lower name}} | |
| capitalize | {{capitalize name}} | |
| truncate | {{truncate text 50}} | |
| String (adv) | replace | {{replace text "a" "b"}} |
| contains | {{contains str "sub"}} | |
| starts_with | {{starts_with str "pre"}} | |
| ends_with | {{ends_with str "suf"}} | |
| pad_left | {{pad_left n 5 "0"}} | |
| pad_right | {{pad_right s 10 "."}} | |
| Array | length | {{length items}} |
| first | {{first items}} | |
| last | {{last items}} | |
| join | {{join items ", "}} | |
| Math | add | {{add a b}} |
| subtract | {{subtract a b}} | |
| multiply | {{multiply a b}} | |
| divide | {{divide a b}} | |
| mod | {{mod a b}} | |
| round | {{round n 2}} | |
| Numbers | commas | {{commas 1234567}} |
| format_number | {{format_number pi 2}} | |
| currency | {{currency price "USD"}} | |
| percent | {{percent ratio 1}} | |
| Date/Time | date_format | {{date_format ts "%Y-%m-%d"}} |
| time_ago | {{time_ago timestamp}} | |
| plural | {{plural n "item" "items"}} | |
| URL/HTML | url_encode | {{url_encode query}} |
| url_decode | {{url_decode str}} | |
| strip_tags | {{strip_tags html}} | |
| Utility | repeat | {{repeat "=" 20}} |
| iif | {{iif cond "yes" "no"}} | |
| json | {{json object}} |
Compiled-template caching
Rendering a template means walking its text and resolving every
{{...}} tag, loop, and conditional. Done naively that structural
work — splitting literals, finding block boundaries, and re-parsing each
loop and if body once per iteration — would repeat on every
single render. For a template rendered on every web request, that is pure waste.
Forma avoids it automatically. The first time you render a template by name,
Forma::render($name, $vars) parses it into a compiled form (a node
list) and caches that, keyed by the template name. Every later render with the
same name skips the parse entirely and just executes the compiled form against
your variables. Loop and conditional bodies are parsed once instead of
once per iteration. Output is byte-for-byte identical to the uncached path.
In practice this makes a hot template render several times faster — roughly 3× on loop-heavy templates and 5× or more on field-heavy ones.
It is automatic — there is no special mode to enable
Caching is on by default. You get the speedup simply by rendering the same template name more than once (which a web server does on every request). The only thing to know:
- Use
Forma::render("name.html", \%vars)for the cached fast path. Partials ({{> nav.html}}) and layouts go throughrender()too, so they are cached as well. Forma::render_string($template, \%vars)has no name to key a cache on, so it compiles fresh every call. Preferrender($name)on hot paths.
# Worker init (once)
Forma::init("/var/www/templates");
Forma::set_cache(1); # already the default
# Per request: compiled once, reused on every later request
my str $page = Forma::render("dashboard.html", \%vars);
Development: live template edits
While iterating on template files, disable caching so changes show up without a restart (each render then re-reads and re-compiles), or clear the cache when a file changes:
Forma::set_cache(0); # re-read + re-compile on every render
# ...or, keep caching on and drop it when a template changes:
Forma::clear_cache();
Best Practices
- Use layouts: Keep common HTML structure in a layout file to avoid repetition.
- Use render_safe() for user-generated content to prevent XSS attacks.
- Enable caching in production: Templates are cached by default for performance.
- Disable caching in development: Use
Forma::set_cache(0)to see template changes immediately. - Pre-compute booleans: Set
has_itemsin your code rather than checking array length in templates. - Use partials: Break large templates into smaller, reusable pieces with
include().