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}} - Template caching for performance
- 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.
my str $html = Forma::render("page.html", \%vars);
Forma::render_string($template, $vars)
Render a template string directly.
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 template caching.
Forma::set_cache(0); # Disable for development
Forma::set_cache(1); # Enable for production (default)
Forma::clear_cache()
Clear the template cache.
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}} |
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().