Chapter 4: Templates
1. Beyond JSON -- Rendering HTML
Every route so far returns JSON. That works for APIs. Web applications need HTML -- product listings, dashboards, login forms, email templates. Tina4 uses the Frond template engine for this work.
Frond is a zero-dependency template engine built from scratch. Its syntax matches Twig, Jinja2, and Nunjucks. Three constructs drive the entire engine: {{ }} for output, {% %} for logic, {# #} for comments. That is the whole grammar.
Templates live in src/templates/. When you call response.render("page.html", data), Frond loads src/templates/page.html, processes the tags and expressions, and returns the final HTML.
This chapter builds toward a product catalog page. Items in a grid. Featured products highlighted. Prices formatted. Layout inherited from a shared template. One engine handles it all.
2. Variables and Expressions
Output a variable with double curly braces:
<h1>Hello, {{ name }}!</h1>Route handler:
Tina4::Router.get("/welcome") do |request, response|
response.render("welcome.html", { name: "Alice" })
endCreate src/templates/welcome.html:
<!DOCTYPE html>
<html>
<head><title>Welcome</title></head>
<body>
<h1>Hello, {{ name }}!</h1>
</body>
</html>Browser output:
Hello, Alice!Accessing Nested Data
Dot notation reaches into nested hashes:
data = {
user: {
name: "Alice",
email: "alice@example.com",
address: {
city: "Cape Town",
country: "South Africa"
}
}
}
response.render("profile.html", data)<p>{{ user.name }} lives in {{ user.address.city }}, {{ user.address.country }}.</p>Output:
Alice lives in Cape Town, South Africa.Method Calls on Values
If a Hash value is a Proc or lambda, you can call it directly in a template expression:
data = {
user: {
name: "Alice",
t: ->(key) { I18n.t(key) },
greet: ->(greeting) { "#{greeting}, Alice!" }
}
}
response.render("page.twig", data)<p>{{ user.t("welcome_message") }}</p>
<p>{{ user.greet("Hello") }}</p>This works for any callable value -- Proc, lambda, or method objects. Arguments are parsed from the parenthesized expression.
Expressions
Basic expressions work inside {{ }}:
<p>Total: ${{ price * quantity }}</p>
<p>Discounted: ${{ price * 0.9 }}</p>
<p>Full name: {{ first_name ~ " " ~ last_name }}</p>The ~ operator concatenates strings.
3. Template Inheritance
Template inheritance is the headline feature. Define a base layout once. Extend it in every page.
Base Layout
Create src/templates/base.twig:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}My App{% endblock %}</title>
<link rel="stylesheet" href="/css/tina4.css">
{% block head %}{% endblock %}
</head>
<body>
<nav>
<a href="/">Home</a>
<a href="/about">About</a>
<a href="/contact">Contact</a>
</nav>
<main>
{% block content %}{% endblock %}
</main>
<footer>
<p>© 2026 My App. All rights reserved.</p>
</footer>
<script src="/js/frond.js"></script>
{% block scripts %}{% endblock %}
</body>
</html>Four blocks: title, head, content, scripts. Child templates override only the blocks they need.
Child Template
Create src/templates/about.twig:
{% extends "base.twig" %}
{% block title %}About Us{% endblock %}
{% block content %}
<h1>About Us</h1>
<p>We have been building things since {{ founded_year }}.</p>
<p>Our team has {{ team_size }} members across {{ office_count }} offices.</p>
{% endblock %}Route handler:
Tina4::Router.get("/about") do |request, response|
response.render("about.twig", {
founded_year: 2020,
team_size: 12,
office_count: 3
})
endBrowser output: A full HTML page with the nav, the "About Us" content, and the footer. The <title> tag reads "About Us". The head and scripts blocks stay empty -- the child did not override them.
Using {{ parent() }}
Add to a block instead of replacing it:
{% extends "base.twig" %}
{% block head %}
{{ parent() }}
<link rel="stylesheet" href="/css/contact-form.css">
{% endblock %}
{% block content %}
<h1>Contact Us</h1>
<form>...</form>
{% endblock %}The head block now contains everything from the base plus the extra stylesheet.
4. Includes
Break templates into reusable pieces with {% include %}:
Create src/templates/partials/header.twig:
<header>
<div class="logo">{{ site_name | default("My App") }}</div>
<nav>
<a href="/">Home</a>
<a href="/products">Products</a>
<a href="/contact">Contact</a>
</nav>
</header>Create src/templates/partials/product-card.twig:
<div class="product-card{{ product.featured ? ' featured' : '' }}">
<h3>{{ product.name }}</h3>
<p class="price">${{ product.price | number_format(2) }}</p>
{% if product.in_stock %}
<span class="badge-success">In Stock</span>
{% else %}
<span class="badge-danger">Out of Stock</span>
{% endif %}
</div>Use them in a page:
{% extends "base.twig" %}
{% block content %}
{% include "partials/header.twig" %}
<h1>Products</h1>
{% for product in products %}
{% include "partials/product-card.twig" %}
{% endfor %}
{% endblock %}The product variable is available inside the included template because it exists in the current scope (the for loop).
Passing Variables to Includes
Pass specific variables with with:
{% include "partials/header.twig" with {"site_name": "Cool Store"} %}Use only to isolate the included template from the parent scope:
{% include "partials/header.twig" with {"site_name": "Cool Store"} only %}With only, the included template sees site_name and nothing else.
5. For Loops
Loop through arrays with {% for %}:
<ul>
{% for item in items %}
<li>{{ item }}</li>
{% endfor %}
</ul>The loop Variable
Inside a for loop, Frond provides a special loop variable:
| Property | Type | Description |
|---|---|---|
loop.index | int | Current iteration (1-based) |
loop.index0 | int | Current iteration (0-based) |
loop.first | bool | True on the first iteration |
loop.last | bool | True on the last iteration |
loop.length | int | Total number of items |
loop.revindex | int | Iterations remaining (1-based) |
<table>
<thead>
<tr><th>#</th><th>Name</th><th>Price</th></tr>
</thead>
<tbody>
{% for product in products %}
<tr class="{{ loop.index is odd ? 'row-light' : 'row-dark' }}">
<td>{{ loop.index }}</td>
<td>{{ product.name }}</td>
<td>${{ product.price | number_format(2) }}</td>
</tr>
{% endfor %}
</tbody>
</table>Empty Lists
Handle empty lists with {% else %}:
{% for product in products %}
<div class="product-card">
<h3>{{ product.name }}</h3>
</div>
{% else %}
<p>No products found.</p>
{% endfor %}If products is empty or undefined, the else block renders instead.
Looping Over Key-Value Pairs
{% for key, value in metadata %}
<dt>{{ key }}</dt>
<dd>{{ value }}</dd>
{% endfor %}6. Conditionals
if / elseif / else
{% if user.role == "admin" %}
<a href="/admin">Admin Panel</a>
{% elseif user.role == "editor" %}
<a href="/editor">Editor Dashboard</a>
{% else %}
<a href="/profile">My Profile</a>
{% endif %}Ternary Operator
Inline conditionals:
<span class="{{ is_active ? 'text-green' : 'text-gray' }}">
{{ is_active ? 'Active' : 'Inactive' }}
</span>Testing for Existence
Use is defined to check if a variable exists:
{% if error_message is defined %}
<div class="alert alert-danger">{{ error_message }}</div>
{% endif %}Truthiness
These values are false: false, nil, 0, "" (empty string), [] (empty array). Everything else is true.
{% if items %}
<p>{{ items | length }} items found.</p>
{% else %}
<p>No items.</p>
{% endif %}{% set %} -- Local Variables
Create or update a variable inside a template:
{% set greeting = "Hello" %}
{% set full_name = user.first_name ~ " " ~ user.last_name %}
{% set total = price * quantity %}
{% set discount = total - rebate %}
<p>{{ greeting }}, {{ full_name }}!</p>
<p>Total: {{ total }}, After discount: {{ discount }}</p>The ~ operator concatenates strings. Arithmetic operators (+, -, *, /, //, %, **) work in set and expressions.
When combining filters with arithmetic, assign the filtered values first:
{% set dr = account.dr|default(0) %}
{% set cr = account.cr|default(0) %}
{% set balance = dr - cr %}
<p>Balance: {{ balance }}</p>7. Filters
Filters transform values. Apply them with the | (pipe) character:
{{ name | upper }}Complete Filter Reference
String Filters
| Filter | Example | Description |
|---|
| upper | {{ name \| upper }} | Convert to uppercase | | lower | {{ name \| lower }} | Convert to lowercase | | capitalize | {{ name \| capitalize }} | Capitalize first letter | | title | {{ name \| title }} | Capitalize each word | | trim | {{ name \| trim }} | Strip leading/trailing whitespace | | ltrim | {{ name \| ltrim }} | Strip leading whitespace | | rtrim | {{ name \| rtrim }} | Strip trailing whitespace | | slug | {{ title \| slug }} | Convert to URL-friendly slug | | wordwrap(80) | {{ text \| wordwrap(80) }} | Wrap text at N characters | | truncate(100) | {{ text \| truncate(100) }} | Truncate to N characters with ellipsis | | nl2br | {{ text \| nl2br }} | Convert newlines to <br> tags | | striptags | {{ html \| striptags }} | Remove all HTML tags | | replace("a", "b") | {{ text \| replace("old", "new") }} | Replace occurrences of a substring |
Array Filters
| Filter | Example | Description |
|---|
| length | {{ items \| length }} | Count items in array or string length | | reverse | {{ items \| reverse }} | Reverse order of items | | sort | {{ items \| sort }} | Sort items ascending | | shuffle | {{ items \| shuffle }} | Randomly shuffle items | | first | {{ items \| first }} | Get the first item | | last | {{ items \| last }} | Get the last item | | join(", ") | {{ items \| join(", ") }} | Join array items with separator | | split(",") | {{ csv \| split(",") }} | Split string into array | | unique | {{ items \| unique }} | Remove duplicate values | | filter | {{ items \| filter }} | Remove falsy values from array | | map("name") | {{ items \| map("name") }} | Extract a property from each item | | column("name") | {{ items \| column("name") }} | Extract a column from array of objects | | batch(3) | {{ items \| batch(3) }} | Group items into batches of N | | slice(0, 3) | {{ items \| slice(0, 3) }} | Extract a slice from offset with length |
Encoding Filters
| Filter | Example | Description |
|---|
| escape (e) | {{ text \| escape }} | HTML-escape special characters | | raw (safe) | {{ html \| raw }} | Output without auto-escaping | | url_encode | {{ text \| url_encode }} | URL-encode a string | | base64_encode (base64encode) | {{ text \| base64_encode }} | Base64-encode a string | | base64_decode (base64decode) | {{ data \| base64_decode }} | Base64-decode a string | | md5 | {{ text \| md5 }} | Compute MD5 hash | | sha256 | {{ text \| sha256 }} | Compute SHA-256 hash |
Numeric Filters
| Filter | Example | Description |
|---|
| abs | {{ num \| abs }} | Absolute value | | round(2) | {{ price \| round(2) }} | Round to N decimal places | | number_format(2) | {{ price \| number_format(2) }} | Format with decimals and thousands separator | | int | {{ val \| int }} | Cast to integer | | float | {{ val \| float }} | Cast to float | | string | {{ val \| string }} | Cast to string |
JSON Filters
| Filter | Example | Description |
|---|
| json_encode | {{ data \| json_encode }} | Encode value as JSON string | | to_json (tojson) | {{ data \| to_json }} | Encode value as JSON string (alias) | | json_decode | {{ str \| json_decode }} | Decode JSON string to object | | js_escape | {{ text \| js_escape }} | Escape string for safe use in JavaScript |
Dict Filters
| Filter | Example | Description |
|---|
| keys | {{ obj \| keys }} | Get dictionary keys as array | | values | {{ obj \| values }} | Get dictionary values as array | | merge(other) | {{ defaults \| merge(overrides) }} | Merge two dictionaries |
Other Filters
| Filter | Example | Description |
|---|
| default("fallback") | {{ name \| default("Guest") }} | Fallback when value is empty or undefined | | date("Y-m-d") | {{ created \| date("Y-m-d") }} | Format a date value | | format(val) | {{ "%.2f" \| format(price) }} | Format string with value (sprintf-style) | | data_uri | {{ content \| data_uri }} | Convert to a data URI string | | dump | {{ var \| dump }} or {{ dump(var) }} | Debug output — gated on TINA4_DEBUG=true (see Dumping Values) | | form_token | {{ form_token() }} | Generate a CSRF hidden input with token | | formTokenValue | {{ formTokenValue("context") }} | Return just the raw JWT token string | | to_json | {{ data \| to_json }} | JSON-encode a value (safe, no double-escaping) | | js_escape | {{ text \| js_escape }} | Escape for safe use in JavaScript strings |
Chaining Filters
Filters chain left to right:
{{ name | trim | lower | capitalize }}
{# " ALICE SMITH " -> "Alice smith" #}Dumping Values for Debugging
The dump helper lets you inspect any variable mid-template. Two interchangeable forms are supported:
{{ user | dump }}
{{ dump(user) }}Both produce the same <pre>-wrapped, HTML-escaped inspect of the value. Handles hashes, arrays, class instances, and cyclic references — Ruby's inspect prints {...} for back-edges.
{{ dump(order) }}
{# Output: #}
{# <pre>#<Order:0x00007f8 @id=42, @items=[...], @total=99.99></pre> #}dump is gated on TINA4_DEBUG=true. In production (env var unset or false) both the filter and function form silently return an empty SafeString. This prevents accidental leaks of internal state, object shapes, or sensitive values into rendered HTML if a developer leaves a {{ dump(x) }} call in a template.
# .env — dev
TINA4_DEBUG=true # dump() outputs the value
# .env — production
TINA4_DEBUG=false # dump() is a no-opYou can rely on this gate for safety, but treat dump as a development-only convenience. For structured output in production code paths, use to_json.
8. Macros
Macros are reusable template functions. Components you define once and call many times.
Defining a Macro
Create src/templates/macros.twig:
{% macro button(text, url, style) %}
<a href="{{ url | default('#') }}" class="btn btn-{{ style | default('primary') }}">
{{ text }}
</a>
{% endmacro %}
{% macro alert(message, type) %}
<div class="alert alert-{{ type | default('info') }}">
{{ message }}
</div>
{% endmacro %}
{% macro input(name, label, type, value) %}
<div class="form-group">
<label for="{{ name }}">{{ label | default(name | capitalize) }}</label>
<input type="{{ type | default('text') }}" id="{{ name }}" name="{{ name }}" value="{{ value | default('') }}">
</div>
{% endmacro %}Using Macros
Import macros in your template:
{% from "macros.twig" import button, alert, input %}
{% extends "base.twig" %}
{% block content %}
{{ alert("Your profile has been updated.", "success") }}
<form method="POST" action="/profile">
{{ input("name", "Full Name", "text", user.name) }}
{{ input("email", "Email Address", "email", user.email) }}
{{ input("phone", "Phone Number", "tel", user.phone) }}
{{ button("Save Changes", "", "primary") }}
{{ button("Cancel", "/dashboard", "secondary") }}
</form>
{% endblock %}Expected output (simplified):
<div class="alert alert-success">
Your profile has been updated.
</div>
<form method="POST" action="/profile">
<div class="form-group">
<label for="name">Full Name</label>
<input type="text" id="name" name="name" value="Alice">
</div>
<div class="form-group">
<label for="email">Email Address</label>
<input type="email" id="email" name="email" value="alice@example.com">
</div>
<div class="form-group">
<label for="phone">Phone Number</label>
<input type="tel" id="phone" name="phone" value="">
</div>
<a href="#" class="btn btn-primary">Save Changes</a>
<a href="/dashboard" class="btn btn-secondary">Cancel</a>
</form>9. Special Tags
{% raw %} -- Literal Output
When you need to output literal {{ }} or {% %} (for Vue.js or Angular templates):
{% raw %}
<div id="app">
{{ message }}
</div>
{% endraw %}Outputs the literal text {{ message }} without processing it as a Frond expression.
{% spaceless %} -- Remove Whitespace
Strip whitespace between HTML tags:
{% spaceless %}
<div>
<span>Hello</span>
</div>
{% endspaceless %}Output:
<div><span>Hello</span></div>Useful for inline elements where whitespace creates unwanted gaps.
{% autoescape %} -- Control Escaping
Override auto-escaping for a block:
{% autoescape false %}
{{ trusted_html }}
{% endautoescape %}Everything inside outputs without HTML escaping. Equivalent to | raw on every variable, but more convenient for large blocks of trusted content.
Comments
Template comments stay invisible in the output:
{# This comment will not appear in the HTML output #}
{#
Multi-line comments work too.
Use them to document template logic.
#}10. tina4css Integration
Every Tina4 project includes tina4.css -- a built-in CSS utility framework available at /css/tina4.css. Layout, typography, common UI patterns. No external dependencies.
Include it in your base template:
<link rel="stylesheet" href="/css/tina4.css">Layout Classes
<div class="container">
<div class="row">
<div class="col-6">Left half</div>
<div class="col-6">Right half</div>
</div>
</div>Common Components
<!-- Buttons -->
<button class="btn btn-primary">Primary</button>
<button class="btn btn-secondary">Secondary</button>
<button class="btn btn-danger">Danger</button>
<!-- Cards -->
<div class="card">
<div class="card-header">Title</div>
<div class="card-body">Content here</div>
<div class="card-footer">Footer</div>
</div>
<!-- Alerts -->
<div class="alert alert-success">Operation completed.</div>
<div class="alert alert-danger">Something went wrong.</div>
<div class="alert alert-warning">Please review your input.</div>
<!-- Forms -->
<div class="form-group">
<label for="name">Name</label>
<input type="text" id="name" class="form-control">
</div>Utility Classes
<p class="text-center">Centered text</p>
<p class="text-right">Right-aligned text</p>
<div class="mt-4">Margin top</div>
<div class="p-3">Padding all around</div>
<span class="text-muted">Gray text</span>
<span class="text-primary">Primary color text</span>No Bootstrap needed. No Tailwind needed. If you prefer those, swap the <link> tag. tina4css stays out of the way.
11. Exercise: Build a Product Catalog Page
Build a product catalog page with a base layout, product cards, category filters, and a reusable card macro.
Requirements
- Create a base layout at
src/templates/catalog-base.twigwith blocks fortitle,content, andscripts - Create a macro file at
src/templates/catalog-macros.twigwith:- A
productCard(product)macro that renders a styled card with name, category, price, stock status, and optional featured badge - A
categoryFilter(categories, active)macro that renders filter buttons
- A
- Create a page template at
src/templates/catalog.twigthat:- Extends the base layout
- Uses the macros
- Shows a heading with total product count
- Shows category filter buttons (All, and one per unique category)
- Shows product cards in a grid
- Shows featured products with a distinct style
- Handles empty filter results
- Create a route at
GET /catalogthat accepts an optional?category=filter
Data
Use this product list in your route handler:
products = [
{ name: "Espresso Machine", category: "Kitchen", price: 299.99, in_stock: true, featured: true },
{ name: "Yoga Mat", category: "Fitness", price: 29.99, in_stock: true, featured: false },
{ name: "Standing Desk", category: "Office", price: 549.99, in_stock: true, featured: true },
{ name: "Blender", category: "Kitchen", price: 89.99, in_stock: false, featured: false },
{ name: "Running Shoes", category: "Fitness", price: 119.99, in_stock: true, featured: false },
{ name: "Desk Lamp", category: "Office", price: 39.99, in_stock: true, featured: true },
{ name: "Cast Iron Skillet", category: "Kitchen", price: 44.99, in_stock: true, featured: false }
]Test with:
http://localhost:7147/catalog
http://localhost:7147/catalog?category=Kitchen
http://localhost:7147/catalog?category=Fitness12. Solution
Create src/templates/catalog-base.twig:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Product Catalog{% endblock %}</title>
<link rel="stylesheet" href="/css/tina4.css">
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; margin: 0; padding: 0; background: #f8f9fa; }
.container { max-width: 1000px; margin: 0 auto; padding: 20px; }
.header { background: #2c3e50; color: white; padding: 20px; margin-bottom: 24px; }
.header h1 { margin: 0; }
.header p { margin: 4px 0 0; opacity: 0.8; }
.filters { margin-bottom: 20px; }
.filter-btn { display: inline-block; padding: 6px 14px; margin: 0 6px 6px 0; border-radius: 20px; text-decoration: none; font-size: 0.9em; border: 1px solid #dee2e6; color: #495057; background: white; }
.filter-btn.active { background: #2c3e50; color: white; border-color: #2c3e50; }
.product-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 16px; }
.product-card { background: white; border: 2px solid #e9ecef; border-radius: 8px; padding: 16px; transition: border-color 0.2s; }
.product-card:hover { border-color: #adb5bd; }
.product-card.featured { border-color: #f39c12; background: #fef9e7; }
.product-name { font-size: 1.1em; font-weight: 600; margin: 0 0 4px; }
.product-category { font-size: 0.85em; color: #6c757d; margin: 0 0 8px; }
.product-price { font-size: 1.2em; font-weight: bold; color: #27ae60; }
.badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 0.75em; font-weight: 600; margin-left: 8px; }
.badge-featured { background: #f39c12; color: white; }
.badge-stock { background: #d4edda; color: #155724; }
.badge-nostock { background: #f8d7da; color: #721c24; }
.empty-state { text-align: center; padding: 40px; color: #6c757d; }
</style>
</head>
<body>
{% block content %}{% endblock %}
<script src="/js/frond.js"></script>
{% block scripts %}{% endblock %}
</body>
</html>Create src/templates/catalog-macros.twig:
{% macro productCard(product) %}
<div class="product-card{{ product.featured ? ' featured' : '' }}">
<p class="product-name">
{{ product.name }}
{% if product.featured %}
<span class="badge badge-featured">Featured</span>
{% endif %}
</p>
<p class="product-category">{{ product.category }}</p>
<p class="product-price">
${{ product.price | number_format(2) }}
{% if product.in_stock %}
<span class="badge badge-stock">In Stock</span>
{% else %}
<span class="badge badge-nostock">Out of Stock</span>
{% endif %}
</p>
</div>
{% endmacro %}
{% macro categoryFilter(categories, active) %}
<div class="filters">
<a href="/catalog" class="filter-btn{{ active is not defined or active == '' ? ' active' : '' }}">All</a>
{% for cat in categories %}
<a href="/catalog?category={{ cat }}" class="filter-btn{{ active == cat ? ' active' : '' }}">{{ cat }}</a>
{% endfor %}
</div>
{% endmacro %}Create src/templates/catalog.twig:
{% extends "catalog-base.twig" %}
{% from "catalog-macros.twig" import productCard, categoryFilter %}
{% block title %}{{ active_category | default("All") }} Products - Catalog{% endblock %}
{% block content %}
<div class="header">
<h1>Product Catalog</h1>
<p>{{ products | length }} product{{ products | length != 1 ? 's' : '' }}{% if active_category %} in {{ active_category }}{% endif %}</p>
</div>
<div class="container">
{{ categoryFilter(categories, active_category) }}
{% if products | length > 0 %}
<div class="product-grid">
{% for product in products %}
{{ productCard(product) }}
{% endfor %}
</div>
{% else %}
<div class="empty-state">
<h2>No products found</h2>
<p>Try a different category or <a href="/catalog">view all products</a>.</p>
</div>
{% endif %}
</div>
{% endblock %}Create src/routes/catalog.rb:
Tina4::Router.get("/catalog") do |request, response|
all_products = [
{ name: "Espresso Machine", category: "Kitchen", price: 299.99, in_stock: true, featured: true },
{ name: "Yoga Mat", category: "Fitness", price: 29.99, in_stock: true, featured: false },
{ name: "Standing Desk", category: "Office", price: 549.99, in_stock: true, featured: true },
{ name: "Blender", category: "Kitchen", price: 89.99, in_stock: false, featured: false },
{ name: "Running Shoes", category: "Fitness", price: 119.99, in_stock: true, featured: false },
{ name: "Desk Lamp", category: "Office", price: 39.99, in_stock: true, featured: true },
{ name: "Cast Iron Skillet", category: "Kitchen", price: 44.99, in_stock: true, featured: false }
]
# Get unique categories
categories = all_products.map { |p| p[:category] }.uniq.sort
# Filter by category if specified
active_category = request.params["category"] || ""
products = if active_category.empty?
all_products
else
all_products.select { |p| p[:category].downcase == active_category.downcase }
end
response.render("catalog.twig", {
products: products,
categories: categories,
active_category: active_category
})
endBrowser output for /catalog:
- A dark header with "Product Catalog" and "7 products"
- Filter buttons: All (active), Fitness, Kitchen, Office
- A grid of 7 product cards
- Three cards (Espresso Machine, Standing Desk, Desk Lamp) wear a gold border and "Featured" badge
- The Blender card wears a red "Out of Stock" badge
Browser output for /catalog?category=Kitchen:
- Header shows "3 products in Kitchen"
- Kitchen filter button is active
- Three cards: Espresso Machine, Blender, Cast Iron Skillet
13. Gotchas
1. {% extends %} Must Be the First Tag
Problem: Template inheritance does not work. The page renders without the base layout.
Cause: {% extends "base.twig" %} must be the first tag in the template. Any text, whitespace, or comment before it breaks inheritance.
Fix: Make {% extends %} the absolute first thing in the file. Move {% from %} imports after the extends tag.
2. Undefined Variables Show Nothing
Problem: {{ username }} renders as empty instead of raising an error.
Cause: Frond outputs nothing for undefined variables. By design. But it can hide bugs.
Fix: Use the default filter: {{ username | default("Guest") }}. Or check with {% if username is defined %}.
3. Auto-Escaping Prevents HTML Output
Problem: You pass HTML content but it appears as literal text in the page.
Cause: Auto-escaping converts < to < and > to > for security.
Fix: Trusted content gets {{ content | raw }}. Never use raw on user-supplied input.
4. Variable Scope in Includes
Problem: A variable defined inside a {% for %} loop vanishes after the loop ends.
Cause: Loop variables are scoped to the loop. They do not leak into the outer scope.
Fix: Use {% set %} before the loop and update inside it. Or restructure to keep logic within the loop.
5. Macro Arguments Are Positional
Problem: {{ button("Click", style="danger") }} does not work as expected.
Cause: Frond macros use positional arguments. Order matters.
Fix: Pass arguments in the order they are defined: {{ button("Click", "/url", "danger") }}. Many optional arguments? Consider passing a single object.
6. Template File Extension Does Not Matter
Problem: Should you use .html, .twig, or .tpl?
Cause: Frond does not care about file extensions. It processes any file in src/templates/.
Fix: Pick one and stay consistent. This book uses .twig for templates with Twig syntax and .html for simple HTML. Both work the same.
7. Filters Are Not Ruby Methods
Problem: {{ items | count }} or {{ name | upcase }} raises an error.
Cause: Frond filters follow Twig conventions, not Ruby conventions.
Fix: Use {{ items | length }} not count. Use {{ name | upper }} not upcase. Use {{ text | lower }} not downcase. See the filter table in section 7.