Widget Support
Tsuku's custom functions and custom block directives can be used to create Magento-style widgets and reusable components.
Magento Widget Compatibility
Tsuku does not interpret Magento's {{widget}} syntax, and it leaves it alone: {{widget ...}}, {{media ...}}, {{trans ...}} and the other Magento directives pass through unchanged (the 1.4.0 changelog confirms this as intended behaviour, not an accident). That gives you two options:
- Leave them in the output and let Magento's template filter expand them later (CMS blocks, CMS pages, e-mails).
- Render the widgets with Tsuku by registering widget functions, as shown on the rest of this page.
Passing Magento Directives Through
$template = '<div class="footer">
<p>@html(store.name)</p>
{{widget type="Magento\Cms\Block\Widget\Block" template="widget/static_block/default.phtml" block_id="footer_links"}}
<img src="{{media url="wysiwyg/logo.png"}}" alt="">
<p>{{trans "Thank you, %name!" name=$customer.name}}</p>
</div>';
$data = ['store' => ['name' => 'Fish & Chips Ltd']];
echo $tsuku->process($template, $data);Output:
<div class="footer">
<p>Fish & Chips Ltd</p>
{{widget type="Magento\Cms\Block\Widget\Block" template="widget/static_block/default.phtml" block_id="footer_links"}}
<img src="{{media url="wysiwyg/logo.png"}}" alt="">
<p>{{trans "Thank you, %name!" name=$customer.name}}</p>
</div>The one exception: {{name}} with nothing but a plain variable path inside is { + the value of name + } ({{customer_name}} renders {Ann}, or {} when the variable is missing). Magento directives always have a keyword and attributes ({{var customer.name}}, {{widget type="..."}}), so they are not affected. See Escapes & Special Characters.
Magento Syntax vs Tsuku
Magento:
{{widget type="Magento\Cms\Block\Widget\Block" template="widget/static_block/default.phtml" block_id="footer_links"}}Tsuku Equivalent:
@cms_block("footer_links", "widget/static_block/default.phtml")Creating Widget Functions
A widget is a function that returns HTML. Its return value is printed as it is: Tsuku neither escapes it nor parses it again ({{...}} or @... inside the returned HTML stays literal text), so escape user data inside the function.
CMS Block Widget
$tsuku->registerFunction('cms_block', function (string $blockId): string {
// Stand-in for your CMS storage (database, cache, Magento block repository, ...)
$blocks = ['footer_links' => '<a href="/about">About</a> | <a href="/contact">Contact</a>'];
return $blocks[$blockId] ?? "<!-- CMS Block '{$blockId}' not found -->";
});
// Usage
$template = '<footer>
@cms_block("footer_links")
@cms_block("missing_block")
</footer>';
echo $tsuku->process($template, []);
// Output:
// <footer>
// <a href="/about">About</a> | <a href="/contact">Contact</a>
// <!-- CMS Block 'missing_block' not found -->
// </footer>Product Widget
$tsuku->registerFunction('product_widget', function (string $category, int $limit = 5): string {
// Stand-in for a product query
$catalog = [
'Electronics' => [
['name' => 'Headphones', 'price' => 149.99],
['name' => 'Smart Watch', 'price' => 399],
['name' => 'USB-C Hub', 'price' => 49.5],
],
];
$html = '<div class="widget-product-list">';
$html .= '<h3>Products in ' . htmlspecialchars($category, ENT_QUOTES, 'UTF-8') . '</h3>';
foreach (array_slice($catalog[$category] ?? [], 0, $limit) as $product) {
$html .= sprintf(
'<div class="product"><h4>%s</h4><p>$%s</p></div>',
htmlspecialchars($product['name'], ENT_QUOTES, 'UTF-8'),
number_format($product['price'], 2)
);
}
$html .= '</div>';
return $html;
});
// Usage
$template = '@product_widget("Electronics", 2)';
echo $tsuku->process($template, []);
// Output: <div class="widget-product-list"><h3>Products in Electronics</h3><div class="product"><h4>Headphones</h4><p>$149.99</p></div><div class="product"><h4>Smart Watch</h4><p>$399.00</p></div></div>A number written in the template (the 2) reaches the function as the string "2"; the int type converts it.
Newsletter Widget
$tsuku->registerFunction('newsletter_widget', function(string $title = 'Subscribe', string $buttonText = 'Subscribe'): string {
return <<<HTML
<div class="widget-newsletter">
<h4>{$title}</h4>
<form method="post" action="/newsletter/subscribe">
<input type="email" name="email" placeholder="Your email" required>
<button type="submit">{$buttonText}</button>
</form>
</div>
HTML;
});
// Usage
$template = '@newsletter_widget("Stay Updated", "Join Now")';
echo $tsuku->process($template, []);Output:
<div class="widget-newsletter">
<h4>Stay Updated</h4>
<form method="post" action="/newsletter/subscribe">
<input type="email" name="email" placeholder="Your email" required>
<button type="submit">Join Now</button>
</form>
</div>Banner Widget
$tsuku->registerFunction('banner_widget', function(string $title, string $image, string $link = '#'): string {
$safeTitle = htmlspecialchars($title, ENT_QUOTES, 'UTF-8');
$safeImage = htmlspecialchars($image, ENT_QUOTES, 'UTF-8');
$safeLink = htmlspecialchars($link, ENT_QUOTES, 'UTF-8');
return <<<HTML
<div class="widget-banner">
<a href="{$safeLink}">
<img src="{$safeImage}" alt="{$safeTitle}">
</a>
</div>
HTML;
});
// Usage
$template = '@banner_widget("Summer Sale", "/images/summer.jpg", "/sale")';
echo $tsuku->process($template, []);Output:
<div class="widget-banner">
<a href="/sale">
<img src="/images/summer.jpg" alt="Summer Sale">
</a>
</div>Generic Widget Handler
Create a generic widget system similar to Magento, with one function that dispatches on the widget type:
$tsuku->registerFunction('widget', function (string $type, ...$params): string {
$renderers = [
'cms_block' => fn(string $id = ''): string => '<div class="cms-block" data-block-id="' . htmlspecialchars($id, ENT_QUOTES, 'UTF-8') . '"></div>',
'newsletter' => fn(string $title = 'Subscribe'): string => '<div class="widget-newsletter"><h4>' . htmlspecialchars($title, ENT_QUOTES, 'UTF-8') . '</h4></div>',
];
return isset($renderers[$type])
? $renderers[$type](...$params)
: "<!-- Unknown widget: {$type} -->";
});
// Usage
$template = '@widget("cms_block", "footer_links")
@widget("newsletter", "Stay Updated")
@widget("carousel")';
echo $tsuku->process($template, []);
// Output:
// <div class="cms-block" data-block-id="footer_links"></div>
// <div class="widget-newsletter"><h4>Stay Updated</h4></div>
// <!-- Unknown widget: carousel -->Conditional Widgets
Use widgets with conditionals. The @if and @end lines produce no output of their own:
// Short versions of the widgets above
$tsuku->registerFunction('newsletter_widget', fn(string $title = 'Subscribe'): string => "<form class=\"newsletter\"><h4>{$title}</h4></form>");
$tsuku->registerFunction('product_widget', fn(string $category, int $limit = 5): string => "<div class=\"products\" data-category=\"{$category}\" data-limit=\"{$limit}\"></div>");
$template = '<div class="sidebar">
@if(show_newsletter)
@newsletter_widget("Subscribe Now")
@end
@if(featured_category)
@product_widget(featured_category, 4)
@end
</div>';
$data = [
'show_newsletter' => true,
'featured_category' => 'Electronics',
];
echo $tsuku->process($template, $data);
// Output:
// <div class="sidebar">
// <form class="newsletter"><h4>Subscribe Now</h4></form>
// <div class="products" data-category="Electronics" data-limit="4"></div>
// </div>Widgets in Loops
Directive-only lines can be indented like the HTML around them; only the content lines (here the widget calls) are printed, with their own indentation:
$tsuku->registerFunction('product_widget', fn(string $category, int $limit = 5): string => "<div class=\"products\" data-category=\"{$category}\" data-limit=\"{$limit}\"></div>");
$tsuku->registerFunction('banner_widget', fn(string $title, string $image, string $link = '#'): string => "<a href=\"{$link}\"><img src=\"{$image}\" alt=\"{$title}\"></a>");
$tsuku->registerFunction('newsletter_widget', fn(string $title = 'Subscribe'): string => "<form class=\"newsletter\"><h4>{$title}</h4></form>");
$template = '@for(sections as section)
<section>
<h2>@html(section.title)</h2>
@match(section.type)
@case("products")
@product_widget(section.category, section.limit)
@case("banner")
@banner_widget(section.title, section.image, section.link)
@case("newsletter")
@newsletter_widget(section.title)
@end
</section>
@end';
$data = ['sections' => [
['type' => 'products', 'title' => 'Top Sellers', 'category' => 'Electronics', 'limit' => 4],
['type' => 'banner', 'title' => 'Summer Sale', 'image' => '/images/summer.jpg', 'link' => '/sale'],
['type' => 'newsletter', 'title' => 'Stay Updated'],
]];
echo $tsuku->process($template, $data);Output:
<section>
<h2>Top Sellers</h2>
<div class="products" data-category="Electronics" data-limit="4"></div>
</section>
<section>
<h2>Summer Sale</h2>
<a href="/sale"><img src="/images/summer.jpg" alt="Summer Sale"></a>
</section>
<section>
<h2>Stay Updated</h2>
<form class="newsletter"><h4>Stay Updated</h4></form>
</section>Widget Containers with Custom Directives
A function widget renders everything itself. When the widget should wrap template content - a box with a title around a product list - register a custom block directive (1.4.0). The handler receives the rendered body first, then the arguments:
$tsuku->registerDirective('widget_box', function (string $body, string $title, string $class = 'widget'): string {
return '<div class="' . htmlspecialchars($class, ENT_QUOTES, 'UTF-8') . "\">\n"
. '<h4>' . htmlspecialchars($title, ENT_QUOTES, 'UTF-8') . "</h4>\n"
. $body
. '</div>';
});
$template = '@widget_box("Top Sellers", "widget widget-products")
@for(products as product)
<p>@html(product.name)</p>
@end
@end';
$data = ['products' => [['name' => 'Headphones'], ['name' => 'Smart Watch']]];
echo $tsuku->process($template, $data);Output:
<div class="widget widget-products">
<h4>Top Sellers</h4>
<p>Headphones</p>
<p>Smart Watch</p>
</div>A registered directive name takes precedence over a function with the same name, so do not register widget_box as both.
Magento Integration Example
Complete example integrating with Magento 2. The renderer gets the repositories through constructor injection and registers its widget functions once:
use Magento\Catalog\Api\CategoryRepositoryInterface;
use Magento\Catalog\Model\ResourceModel\Product\CollectionFactory;
use Magento\Cms\Api\BlockRepositoryInterface;
use Magento\Framework\Exception\NoSuchEntityException;
use Qoliber\Tsuku\Tsuku;
class TsukuWidgetRenderer
{
private Tsuku $tsuku;
public function __construct(
private readonly BlockRepositoryInterface $blockRepository,
private readonly CategoryRepositoryInterface $categoryRepository,
private readonly CollectionFactory $productCollectionFactory,
) {
$this->tsuku = new Tsuku();
$this->tsuku->registerFunction('cms_block', $this->cmsBlock(...));
$this->tsuku->registerFunction('product_collection', $this->productCollection(...));
}
public function render(string $template, array $data = []): string
{
return $this->tsuku->process($template, $data);
}
private function cmsBlock(string $identifier): string
{
try {
return (string) $this->blockRepository->getById($identifier)->getContent();
} catch (NoSuchEntityException) {
return "<!-- CMS Block not found: {$identifier} -->";
}
}
private function productCollection(int $categoryId, int $limit = 5): string
{
$category = $this->categoryRepository->get($categoryId);
$products = $this->productCollectionFactory->create()
->addAttributeToSelect(['name', 'price'])
->addCategoryFilter($category)
->setPageSize($limit);
$html = '<div class="product-collection">';
foreach ($products as $product) {
$html .= sprintf(
'<div class="product"><h3>%s</h3><p>$%s</p></div>',
htmlspecialchars((string) $product->getName(), ENT_QUOTES, 'UTF-8'),
number_format((float) $product->getPrice(), 2)
);
}
return $html . '</div>';
}
}
// Use in templates
$html = $renderer->render('<div class="homepage">
@cms_block("header_promo")
<main>
@product_collection(12, 8)
</main>
<footer>
@cms_block("footer_info")
</footer>
</div>');CMS block content often contains {{widget}} or {{media}} directives itself. Tsuku leaves them in the output, so run the result through Magento's CMS template filter (Magento\Cms\Model\Template\FilterProvider::getBlockFilter()) when they should be expanded.
Best Practices
1. Escape Output
Always escape user-provided data:
$tsuku->registerFunction('custom_widget', function(string $userInput): string {
$safe = htmlspecialchars($userInput, ENT_QUOTES, 'UTF-8');
return "<div>{$safe}</div>";
});
$template = '@custom_widget(comment)';
$data = ['comment' => '<script>alert(1)</script>'];
// Output: <div><script>alert(1)</script></div>2. Cache Widget Output
For expensive operations, cache results:
$cache = [];
$tsuku->registerFunction('expensive_widget', function(string $id) use (&$cache): string {
if (isset($cache[$id])) {
return $cache[$id];
}
$result = performExpensiveOperation($id);
$cache[$id] = $result;
return $result;
});3. Provide Fallbacks
Handle missing data gracefully. A function that throws renders as empty output in the default SILENT mode (and throws a TsukuException in STRICT mode), so return a visible fallback instead of failing silently:
$tsuku->registerFunction('cms_block', function(string $blockId): string {
$block = getCmsBlock($blockId);
if (!$block) {
return '<!-- Block not found -->';
}
return $block['content'];
});4. Document Widget Parameters
/**
* Product widget
*
* @param string $category Category slug
* @param int $limit Number of products (default: 5)
* @param string $template Template to use (default: 'grid')
* @return string Rendered HTML
*/
$tsuku->registerFunction('product_widget', function(
string $category,
int $limit = 5,
string $template = 'grid'
): string {
// Implementation
});5. Pick Callable Names
Function names are used as @name(...). Names that start with end, else or default, and the directive names for, if, unless, match, case, elseif, can be registered but never called - the template reads them as directives. @widget(...), @cms_block(...) and @product_widget(...) are fine.
Converting Magento Templates
If you want Tsuku to render the widgets itself (instead of passing the {{widget}} directives through to Magento), convert them:
Before (Magento):
<div class="footer">
{{widget type="Magento\Cms\Block\Widget\Block" block_id="footer_links"}}
{{widget type="Magento\Newsletter\Block\Subscribe" template="subscribe.phtml"}}
</div>After (Tsuku):
$template = '<div class="footer">
@cms_block("footer_links")
@newsletter_widget("Subscribe")
</div>';Next Steps
- Custom Functions → - Learn function basics
- Custom Directives → - Block directives that wrap content
- Security → - Secure your widgets
- Examples → - See widgets in e-commerce