HTML Generation
Tsuku is not a page-layout engine like Twig or Blade: it has no inheritance or includes. It fits HTML that is built from data - product cards, account tables, transactional emails, form fields, widget markup.
Tsuku never escapes on its own. Every string from the data goes through @html(), in text and in attribute values; integers such as ids and counts are printed with {...}. Each example has the template, the data, the generated output and the PHP call (how the examples work), followed by a preview of the output styled with a little example CSS.
Product grid
A category grid: a loop over the products, a badge picked by an @if / @elseif chain using in and and, a sale price only when it is lower than the regular price, and a disabled button for a product that is out of stock. The stars are a list prepared in PHP, because templates have no arithmetic. Directives are indented with the markup they control; their lines print nothing.
<section class="product-grid" aria-label="@html(category.name)">
@for(products as product)
<article class="product-card" aria-labelledby="product-{product.id}">
@if("new" in product.labels)
<span class="badge badge-new">New</span>
@elseif(product.special_price and product.special_price < product.price)
<span class="badge badge-sale">Sale</span>
@elseif("bestseller" in product.labels)
<span class="badge">Bestseller</span>
@end
<a href="@html(product.url)">
<img src="@html(product.image)" alt="@html(product.name)" width="320" height="240">
</a>
<h3 id="product-{product.id}"><a href="@html(product.url)">@html(product.name)</a></h3>
<p class="rating" aria-label="Rated @number(product.rating, 1) out of 5">
@for(product.stars as filled)
<span class="star @?{filled "filled" : "empty"}" aria-hidden="true">★</span>
@end
<span class="count">({product.review_count})</span>
</p>
<p class="price">
@if(product.special_price and product.special_price < product.price)
<del>€@number(product.price, 2)</del>
<ins>€@number(product.special_price, 2)</ins>
@else
€@number(product.price, 2)
@end
</p>
@if(product.qty > 0)
<button type="button" class="btn">Add to cart</button>
@else
<button type="button" class="btn" disabled>Out of stock</button>
@end
</article>
@end
</section><?php
// Five stars, filled up to the rounded rating: templates have no arithmetic, so PHP prepares the list.
$stars = static fn (float $rating): array => array_map(
static fn (int $star): bool => $star <= round($rating),
range(1, 5),
);
return [
'category' => ['name' => 'Teapots & Kettles'],
'products' => [
[
'id' => 101,
'name' => 'Kyusu Teapot 350 ml',
'url' => '/kyusu-teapot-350-ml',
'image' => '/examples/media/teapot.svg',
'labels' => ['new'],
'price' => 49.00,
'special_price' => null,
'rating' => 4.4,
'stars' => $stars(4.4),
'review_count' => 38,
'qty' => 12,
],
[
'id' => 102,
'name' => 'Matcha Whisk & Bowl Set',
'url' => '/matcha-whisk-bowl-set',
'image' => '/examples/media/whisk.svg',
'labels' => [],
'price' => 39.90,
'special_price' => 29.90,
'rating' => 4.2,
'stars' => $stars(4.2),
'review_count' => 12,
'qty' => 5,
],
[
'id' => 103,
'name' => 'Cast Iron Kettle "Tetsubin" 0.8 l',
'url' => '/cast-iron-kettle-tetsubin',
'image' => '/examples/media/kettle.svg',
'labels' => ['bestseller'],
'price' => 89.00,
'special_price' => null,
'rating' => 4.8,
'stars' => $stars(4.8),
'review_count' => 51,
'qty' => 0,
],
],
];<section class="product-grid" aria-label="Teapots & Kettles">
<article class="product-card" aria-labelledby="product-101">
<span class="badge badge-new">New</span>
<a href="/kyusu-teapot-350-ml">
<img src="/examples/media/teapot.svg" alt="Kyusu Teapot 350 ml" width="320" height="240">
</a>
<h3 id="product-101"><a href="/kyusu-teapot-350-ml">Kyusu Teapot 350 ml</a></h3>
<p class="rating" aria-label="Rated 4.4 out of 5">
<span class="star filled" aria-hidden="true">★</span>
<span class="star filled" aria-hidden="true">★</span>
<span class="star filled" aria-hidden="true">★</span>
<span class="star filled" aria-hidden="true">★</span>
<span class="star empty" aria-hidden="true">★</span>
<span class="count">(38)</span>
</p>
<p class="price">
€49.00
</p>
<button type="button" class="btn">Add to cart</button>
</article>
<article class="product-card" aria-labelledby="product-102">
<span class="badge badge-sale">Sale</span>
<a href="/matcha-whisk-bowl-set">
<img src="/examples/media/whisk.svg" alt="Matcha Whisk & Bowl Set" width="320" height="240">
</a>
<h3 id="product-102"><a href="/matcha-whisk-bowl-set">Matcha Whisk & Bowl Set</a></h3>
<p class="rating" aria-label="Rated 4.2 out of 5">
<span class="star filled" aria-hidden="true">★</span>
<span class="star filled" aria-hidden="true">★</span>
<span class="star filled" aria-hidden="true">★</span>
<span class="star filled" aria-hidden="true">★</span>
<span class="star empty" aria-hidden="true">★</span>
<span class="count">(12)</span>
</p>
<p class="price">
<del>€39.90</del>
<ins>€29.90</ins>
</p>
<button type="button" class="btn">Add to cart</button>
</article>
<article class="product-card" aria-labelledby="product-103">
<span class="badge">Bestseller</span>
<a href="/cast-iron-kettle-tetsubin">
<img src="/examples/media/kettle.svg" alt="Cast Iron Kettle "Tetsubin" 0.8 l" width="320" height="240">
</a>
<h3 id="product-103"><a href="/cast-iron-kettle-tetsubin">Cast Iron Kettle "Tetsubin" 0.8 l</a></h3>
<p class="rating" aria-label="Rated 4.8 out of 5">
<span class="star filled" aria-hidden="true">★</span>
<span class="star filled" aria-hidden="true">★</span>
<span class="star filled" aria-hidden="true">★</span>
<span class="star filled" aria-hidden="true">★</span>
<span class="star filled" aria-hidden="true">★</span>
<span class="count">(51)</span>
</p>
<p class="price">
€89.00
</p>
<button type="button" class="btn" disabled>Out of stock</button>
</article>
</section>echo $tsuku->process($template, $data);Customer reviews
Text typed by customers must be escaped. @html() turns the <script> and <b> tags in the reviews into visible text and the quotes in the author's name into ". With {review.body} instead, the script tag would end up in the page.
<section class="reviews">
<h2>Reviews (@length(reviews))</h2>
@for(reviews as review)
<article class="review">
<header>
<strong>@html(review.author)</strong>
<time datetime="@date("Y-m-d", review.created_at)">@date("j M Y", review.created_at)</time>
@if(review.verified)
<span class="badge">Verified buyer</span>
@end
</header>
<p class="stars" aria-label="{review.rating} out of 5">@html(review.stars)</p>
<p>@html(review.body)</p>
</article>
@end
</section><?php
// Everything here was typed by customers, so the template escapes every string with @html().
$stars = static fn (int $rating): string => str_repeat('★', $rating) . str_repeat('☆', 5 - $rating);
return [
'reviews' => [
[
'author' => 'Hana K.',
'created_at' => strtotime('2026-09-14'),
'verified' => true,
'rating' => 5,
'stars' => $stars(5),
'body' => 'Pours without a drip. The <b>filter</b> is fine enough for sencha & gyokuro.',
],
[
'author' => 'Tom "the taster"',
'created_at' => strtotime('2026-09-02'),
'verified' => false,
'rating' => 2,
'stars' => $stars(2),
'body' => '<script>alert("hacked")</script> Lid rattles a bit.',
],
],
];<section class="reviews">
<h2>Reviews (2)</h2>
<article class="review">
<header>
<strong>Hana K.</strong>
<time datetime="2026-09-14">14 Sep 2026</time>
<span class="badge">Verified buyer</span>
</header>
<p class="stars" aria-label="5 out of 5">★★★★★</p>
<p>Pours without a drip. The <b>filter</b> is fine enough for sencha & gyokuro.</p>
</article>
<article class="review">
<header>
<strong>Tom "the taster"</strong>
<time datetime="2026-09-02">2 Sep 2026</time>
</header>
<p class="stars" aria-label="2 out of 5">★★☆☆☆</p>
<p><script>alert("hacked")</script> Lid rattles a bit.</p>
</article>
</section>echo $tsuku->process($template, $data);Order history
The order table of a customer account. @match maps the order status to a label, @case("pending", "processing") takes several values, and @default covers any status the template does not know. @date() formats the timestamps (in PHP's default timezone, UTC here).
<table class="orders">
<caption>Recent orders</caption>
<thead>
<tr>
<th scope="col">Order</th>
<th scope="col">Date</th>
<th scope="col">Total</th>
<th scope="col">Status</th>
</tr>
</thead>
<tbody>
@for(orders as order)
<tr>
<td><a href="/sales/order/view/order_id/{order.id}">#@html(order.increment_id)</a></td>
<td><time datetime="@date("Y-m-d", order.created_at)">@date("j M Y", order.created_at)</time></td>
<td>€@number(order.grand_total, 2)</td>
<td>
@match(order.status)
@case("complete")
<span class="status status-ok">Delivered</span>
@case("pending", "processing")
<span class="status status-busy">Processing</span>
@case("canceled")
<span class="status status-off">Cancelled</span>
@default
<span class="status">@html(order.status_label)</span>
@end
</td>
</tr>
@end
</tbody>
</table><?php
return [
'orders' => [
['id' => 2203, 'increment_id' => '000002203', 'created_at' => strtotime('2026-10-21'), 'grand_total' => 118.80, 'status' => 'processing', 'status_label' => 'Processing'],
['id' => 2154, 'increment_id' => '000002154', 'created_at' => strtotime('2026-09-30'), 'grand_total' => 49.00, 'status' => 'complete', 'status_label' => 'Complete'],
['id' => 2087, 'increment_id' => '000002087', 'created_at' => strtotime('2026-08-12'), 'grand_total' => 1240.00, 'status' => 'payment_review', 'status_label' => 'Payment review'],
['id' => 1990, 'increment_id' => '000001990', 'created_at' => strtotime('2026-07-03'), 'grand_total' => 29.90, 'status' => 'canceled', 'status_label' => 'Canceled'],
],
];<table class="orders">
<caption>Recent orders</caption>
<thead>
<tr>
<th scope="col">Order</th>
<th scope="col">Date</th>
<th scope="col">Total</th>
<th scope="col">Status</th>
</tr>
</thead>
<tbody>
<tr>
<td><a href="/sales/order/view/order_id/2203">#000002203</a></td>
<td><time datetime="2026-10-21">21 Oct 2026</time></td>
<td>€118.80</td>
<td>
<span class="status status-busy">Processing</span>
</td>
</tr>
<tr>
<td><a href="/sales/order/view/order_id/2154">#000002154</a></td>
<td><time datetime="2026-09-30">30 Sep 2026</time></td>
<td>€49.00</td>
<td>
<span class="status status-ok">Delivered</span>
</td>
</tr>
<tr>
<td><a href="/sales/order/view/order_id/2087">#000002087</a></td>
<td><time datetime="2026-08-12">12 Aug 2026</time></td>
<td>€1,240.00</td>
<td>
<span class="status">Payment review</span>
</td>
</tr>
<tr>
<td><a href="/sales/order/view/order_id/1990">#000001990</a></td>
<td><time datetime="2026-07-03">3 Jul 2026</time></td>
<td>€29.90</td>
<td>
<span class="status status-off">Cancelled</span>
</td>
</tr>
</tbody>
</table>echo $tsuku->process($template, $data);Order confirmation email
A complete HTML email with inline styles, as mail clients need them. @@media and support@@shop.example.com use @@, the escape for a literal @ typed into the template; the braces of the CSS rules need no escaping because only {plain.path} is a variable. The total comes from PHP.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Your order #@html(order.increment_id)</title>
<style>
@@media (max-width: 480px) {
.card { padding: 20px !important; }
.sku { display: none !important; }
}
</style>
</head>
<body style="margin:0; padding:24px 12px; background:#f4f4f5; font:15px/1.5 Arial, Helvetica, sans-serif; color:#18181b;">
<table role="presentation" class="card" width="100%" cellpadding="0" cellspacing="0" style="max-width:560px; margin:0 auto; padding:32px; background:#ffffff; border-radius:8px;">
<tr>
<td>
<h1 style="margin:0 0 8px; font-size:22px;">Thanks for your order, @html(order.customer_firstname)!</h1>
<p style="margin:0 0 24px; color:#52525b;">Order #@html(order.increment_id) · @date("F j, Y", order.created_at)</p>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
@for(order.items as item)
<tr>
<td style="padding:8px 0; border-bottom:1px solid #e4e4e7;">
@html(item.name)
<span class="sku" style="color:#71717a; font-size:13px;">· @html(item.sku)</span>
</td>
<td style="padding:8px 0; border-bottom:1px solid #e4e4e7; text-align:right; white-space:nowrap;">{item.qty} × €@number(item.price, 2)</td>
</tr>
@end
<tr>
<td style="padding:8px 0;">Shipping (@html(order.shipping_method))</td>
<td style="padding:8px 0; text-align:right;">
@if(order.shipping_amount > 0)
€@number(order.shipping_amount, 2)
@else
Free
@end
</td>
</tr>
<tr>
<td style="padding:8px 0; font-weight:bold;">Total</td>
<td style="padding:8px 0; font-weight:bold; text-align:right;">€@number(order.grand_total, 2)</td>
</tr>
</table>
@if(order.tracking_url)
<p style="margin:24px 0;">
<a href="@html(order.tracking_url)" style="display:inline-block; padding:10px 18px; background:#3451b2; color:#ffffff; border-radius:6px; text-decoration:none;">Track your parcel</a>
</p>
@end
<p style="margin:24px 0 0; color:#52525b; font-size:13px;">
Shipping to @html(order.shipping_address.name), @html(order.shipping_address.street), @html(order.shipping_address.city).<br>
Questions? Reply to this email or write to support@@shop.example.com.
</p>
</td>
</tr>
</table>
</body>
</html><?php
return [
'order' => [
'increment_id' => '000002203',
'created_at' => strtotime('2026-10-21'),
'customer_firstname' => 'Anna',
'items' => [
['name' => 'Kyusu Teapot 350 ml', 'sku' => 'TEA-KYU-350', 'qty' => 1, 'price' => 49.00],
['name' => 'Sencha Asamushi 100 g', 'sku' => 'TEA-SEN-100', 'qty' => 2, 'price' => 14.90],
['name' => 'Matcha Whisk & Bowl Set', 'sku' => 'TEA-MAT-SET', 'qty' => 1, 'price' => 29.90],
],
'shipping_method' => 'DHL Parcel',
'shipping_amount' => 0,
'grand_total' => 108.70, // calculated in PHP: templates have no arithmetic
'tracking_url' => 'https://track.example.com/?code=JVGL0612345678&lang=en',
'shipping_address' => ['name' => 'Anna de Vries', 'street' => 'Keizersgracht 12', 'city' => 'Amsterdam'],
],
];<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Your order #000002203</title>
<style>
@media (max-width: 480px) {
.card { padding: 20px !important; }
.sku { display: none !important; }
}
</style>
</head>
<body style="margin:0; padding:24px 12px; background:#f4f4f5; font:15px/1.5 Arial, Helvetica, sans-serif; color:#18181b;">
<table role="presentation" class="card" width="100%" cellpadding="0" cellspacing="0" style="max-width:560px; margin:0 auto; padding:32px; background:#ffffff; border-radius:8px;">
<tr>
<td>
<h1 style="margin:0 0 8px; font-size:22px;">Thanks for your order, Anna!</h1>
<p style="margin:0 0 24px; color:#52525b;">Order #000002203 · October 21, 2026</p>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
<tr>
<td style="padding:8px 0; border-bottom:1px solid #e4e4e7;">
Kyusu Teapot 350 ml
<span class="sku" style="color:#71717a; font-size:13px;">· TEA-KYU-350</span>
</td>
<td style="padding:8px 0; border-bottom:1px solid #e4e4e7; text-align:right; white-space:nowrap;">1 × €49.00</td>
</tr>
<tr>
<td style="padding:8px 0; border-bottom:1px solid #e4e4e7;">
Sencha Asamushi 100 g
<span class="sku" style="color:#71717a; font-size:13px;">· TEA-SEN-100</span>
</td>
<td style="padding:8px 0; border-bottom:1px solid #e4e4e7; text-align:right; white-space:nowrap;">2 × €14.90</td>
</tr>
<tr>
<td style="padding:8px 0; border-bottom:1px solid #e4e4e7;">
Matcha Whisk & Bowl Set
<span class="sku" style="color:#71717a; font-size:13px;">· TEA-MAT-SET</span>
</td>
<td style="padding:8px 0; border-bottom:1px solid #e4e4e7; text-align:right; white-space:nowrap;">1 × €29.90</td>
</tr>
<tr>
<td style="padding:8px 0;">Shipping (DHL Parcel)</td>
<td style="padding:8px 0; text-align:right;">
Free
</td>
</tr>
<tr>
<td style="padding:8px 0; font-weight:bold;">Total</td>
<td style="padding:8px 0; font-weight:bold; text-align:right;">€108.70</td>
</tr>
</table>
<p style="margin:24px 0;">
<a href="https://track.example.com/?code=JVGL0612345678&lang=en" style="display:inline-block; padding:10px 18px; background:#3451b2; color:#ffffff; border-radius:6px; text-decoration:none;">Track your parcel</a>
</p>
<p style="margin:24px 0 0; color:#52525b; font-size:13px;">
Shipping to Anna de Vries, Keizersgracht 12, Amsterdam.<br>
Questions? Reply to this email or write to [email protected].
</p>
</td>
</tr>
</table>
</body>
</html>echo $tsuku->process($template, $data);Checkout form
Form fields from configuration. @match(field.type) writes an <input>, a <select> or a <textarea>. Inline blocks such as @if(field.required) required@end add an attribute inside a tag; the space before required is kept because the body is on one line. The customer's earlier input is escaped as well: Ring twice <3 becomes Ring twice <3.
<form class="checkout" method="post" action="@html(form.action)">
<input type="hidden" name="form_key" value="@html(form.key)">
@for(form.fields as field)
<div class="field">
<label for="@html(field.name)">@html(field.label)@if(field.required) <span class="required">*</span>@end</label>
@match(field.type)
@case("text", "email", "tel")
<input type="@html(field.type)" id="@html(field.name)" name="@html(field.name)" value="@html(field.value)" autocomplete="@html(field.autocomplete)"@if(field.required) required@end>
@case("select")
<select id="@html(field.name)" name="@html(field.name)"@if(field.required) required@end>
@for(field.options as option)
<option value="@html(option.value)"@if(option.value == field.value) selected@end>@html(option.label)</option>
@end
</select>
@case("textarea")
<textarea id="@html(field.name)" name="@html(field.name)" rows="2">@html(field.value)</textarea>
@end
@if(field.hint)
<small>@html(field.hint)</small>
@end
</div>
@end
<button type="submit" class="btn">Continue to payment</button>
</form><?php
// The field list comes from configuration; 'value' is what the customer entered before.
return [
'form' => [
'action' => '/checkout/shipping/save',
'key' => 'f7Kq2Lw9',
'fields' => [
['name' => 'firstname', 'label' => 'First name', 'type' => 'text', 'value' => 'Anna', 'autocomplete' => 'given-name', 'required' => true, 'hint' => ''],
['name' => 'email', 'label' => 'Email', 'type' => 'email', 'value' => '[email protected]', 'autocomplete' => 'email', 'required' => true, 'hint' => 'We send the order confirmation here.'],
['name' => 'telephone', 'label' => 'Phone', 'type' => 'tel', 'value' => '', 'autocomplete' => 'tel', 'required' => false, 'hint' => 'Only used by the courier.'],
[
'name' => 'country_id',
'label' => 'Country',
'type' => 'select',
'value' => 'NL',
'required' => true,
'hint' => '',
'options' => [
['value' => 'BE', 'label' => 'Belgium'],
['value' => 'DE', 'label' => 'Germany'],
['value' => 'NL', 'label' => 'Netherlands'],
],
],
['name' => 'comment', 'label' => 'Delivery note', 'type' => 'textarea', 'value' => 'Ring twice <3', 'required' => false, 'hint' => ''],
],
],
];<form class="checkout" method="post" action="/checkout/shipping/save">
<input type="hidden" name="form_key" value="f7Kq2Lw9">
<div class="field">
<label for="firstname">First name <span class="required">*</span></label>
<input type="text" id="firstname" name="firstname" value="Anna" autocomplete="given-name" required>
</div>
<div class="field">
<label for="email">Email <span class="required">*</span></label>
<input type="email" id="email" name="email" value="[email protected]" autocomplete="email" required>
<small>We send the order confirmation here.</small>
</div>
<div class="field">
<label for="telephone">Phone</label>
<input type="tel" id="telephone" name="telephone" value="" autocomplete="tel">
<small>Only used by the courier.</small>
</div>
<div class="field">
<label for="country_id">Country <span class="required">*</span></label>
<select id="country_id" name="country_id" required>
<option value="BE">Belgium</option>
<option value="DE">Germany</option>
<option value="NL" selected>Netherlands</option>
</select>
</div>
<div class="field">
<label for="comment">Delivery note</label>
<textarea id="comment" name="comment" rows="2">Ring twice <3</textarea>
</div>
<button type="submit" class="btn">Continue to payment</button>
</form>echo $tsuku->process($template, $data);Escaping checklist
- Text and attribute values:
@html(value). It escapes& < > " ', so it is safe in double- and single-quoted attributes. - A value inside a URL path or query string:
@url(value). @html()does not make a URL safe to link to: ajavascript:URL is still a link. Check URLs that users entered in PHP.- JSON inside a
<script>tag (JSON-LD):@json(), which also escapes<and>- see JSON-LD product data. - Never print user input with
{...}in HTML. More in Security & Escaping.
Next Steps
- E-commerce → - invoices, labels and plain-text emails
- JSON APIs → - the same kind of data as JSON
- Built-in Functions → - every escaping and formatting function