Skip to content

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.

tsuku-html
<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
<?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,
        ],
    ],
];
html
<section class="product-grid" aria-label="Teapots &amp; 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 &amp; Bowl Set" width="320" height="240">
    </a>
    <h3 id="product-102"><a href="/matcha-whisk-bowl-set">Matcha Whisk &amp; 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 &quot;Tetsubin&quot; 0.8 l" width="320" height="240">
    </a>
    <h3 id="product-103"><a href="/cast-iron-kettle-tetsubin">Cast Iron Kettle &quot;Tetsubin&quot; 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>
php
echo $tsuku->process($template, $data);
Preview

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 &quot;. With {review.body} instead, the script tag would end up in the page.

tsuku-html
<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
<?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.',
        ],
    ],
];
html
<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 &lt;b&gt;filter&lt;/b&gt; is fine enough for sencha &amp; gyokuro.</p>
  </article>
  <article class="review">
    <header>
      <strong>Tom &quot;the taster&quot;</strong>
      <time datetime="2026-09-02">2 Sep 2026</time>
    </header>
    <p class="stars" aria-label="2 out of 5">★★☆☆☆</p>
    <p>&lt;script&gt;alert(&quot;hacked&quot;)&lt;/script&gt; Lid rattles a bit.</p>
  </article>
</section>
php
echo $tsuku->process($template, $data);
Preview

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).

tsuku-html
<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
<?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'],
    ],
];
html
<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>
php
echo $tsuku->process($template, $data);
Preview

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.

tsuku-html
<!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
<?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'],
    ],
];
html
<!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 &amp; 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&amp;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>
php
echo $tsuku->process($template, $data);
Preview (email)

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 &lt;3.

tsuku-html
<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
<?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' => ''],
        ],
    ],
];
html
<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 &lt;3</textarea>
  </div>
  <button type="submit" class="btn">Continue to payment</button>
</form>
php
echo $tsuku->process($template, $data);
Preview

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: a javascript: 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 ​

Released under the MIT License.