Latte as a Ghost in the Machine: a Template Compiler for Tracy
Tracy's templates have always been hand-written
.phtml pages full of htmlspecialchars and nested
ifs. I'll admit it bugged me for a long time. And then a heretical
thought hit me: what if Latte wrote them? But Latte in Tracy? Overkill! Or
rather… it could be done, just not the way you'd expect.
Tracy is a debugger. It has to run in every PHP project that pulls it into
composer.json. In startups, in legacy applications, on shared
hosting from 2014. A heavyweight runtime dependency on a templating system has
no business being there. Period.
But those templates. If you've ever written your own panel for the Tracy bar,
you know what I mean. A few cells in a table, dynamic CSS classes by state,
conditional attributes, and suddenly you've got 35 lines of ugly
.phtml on your hands:
<table class="tracy-sortable">
<thead><tr><th>Time</th><th>Query</th><th>Source</th></tr></thead>
<tbody>
<?php foreach ($queries as $q): ?>
<tr<?php if ($q->error): ?> class="tracy-error"<?php elseif ($q->slow): ?> class="tracy-warning"<?php endif ?>>
<td<?php if ($q->time > 100): ?> title="<?= htmlspecialchars((string) $q->time) ?> ms"<?php endif ?>>
<?= number_format($q->time, 2) ?> ms
</td>
<td><code><?= htmlspecialchars($q->sql) ?></code></td>
<td><?= htmlspecialchars($q->source) ?></td>
</tr>
<?php endforeach ?>
</tbody>
</table>
Count the <?php, ?> and
htmlspecialchars tokens in that template. Then imagine adding a
fourth column six months from now. What a treat. And let me not even start on
the fact that every forgotten htmlspecialchars is a potential XSS.
In a debugger. Lovely.
Latte Instead of Raw PHP
One evening I started wondering what the same table would look like in Latte:
<table class="tracy-sortable">
<thead><tr><th>Time</th><th>Query</th><th>Source</th></tr></thead>
<tbody>
<tr n:foreach="$queries as $q" n:class="$q->slow ? tracy-warning, $q->error ? tracy-error">
<td title={$q->time > 100 ? $q->time . ' ms'}>{$q->time|number:2} ms</td>
<td><code>{$q->sql}</code></td>
<td>{$q->source}</td>
</tr>
</tbody>
</table>
Half the characters. Latte knows what HTML context it's currently
in and escapes accordingly: differently in text than in an attribute,
differently in an attribute than inside a <script>. No manual
htmlspecialchars. No forgetting. No way to get it wrong.
And notice the title attribute. This is Smart
HTML Attributes from Latte 3.1: when the value resolves to
null, the attribute doesn't appear in the output at all.
Beautiful. And absolutely unacceptable. Tracy must not depend on Latte at runtime.
Latte as a Build-Time Compiler
And then it hit me. Latte doesn't have to become part of Tracy. It just has to be part of building Tracy.
The principle is trivial. Templates are written in .latte. At
build time (locally, before commit, or in CI) they get turned into static
.phtml files. Those get committed to the repo. At runtime, Tracy
just requires them. Nothing more. Latte never shows up in
composer.json. The developer writing the templates gets readable
templates; the developer pulling Tracy into their project gets the same
lightweight library as before.
In this role, Latte renders nothing. It takes a template apart and produces PHP that does the same thing. A compiler, not an engine. Once that clicked, the pieces fell into place.
How Template Compilation Works
Latte fortunately has a clean API. Extension points, custom AST nodes, a configurable printer. You only need to write three things:
- a custom engine that, instead of producing a class extending the runtime template, emits flat standalone PHP: a plain echo-based script that needs no object context;
- a custom escaper that, in the generated PHP, calls
Tracy\Helpers::escapeHtml()instead ofLatte\Runtime\Filters::escapeHtmlText(). The reason is obvious: if the generated code called Latte runtime, it would still depend on Latte; - a custom extension that registers only the tags I actually use in the templates.
Most tags I could take from Latte as they are. {if},
{foreach}, {var}, {do},
{=...}. They live entirely at the AST level and the printer
translates them into PHP if, foreach and echo. They
need no runtime.
But then there are tags in Latte that are systemically tied to the runtime,
because Latte normally produces a template class and they work with that
class. {include} does inheritance and block overriding.
{define} generates blocks bound to the template class.
{try} does it via output buffering.
None of that fits in Tracy. No template classes, no hierarchy, no OB. So I rewrote those tags. And here comes the main point: I didn't write my own parser, my own AST, my own traverser. I only wrote my own implementation of the node, one that behaves the way I need:
{include 'file.phtml'}→ a plainrequire __DIR__ . '/file.phtml'. Period.{define name}...{/define}+{include name}→ blocks as closures in a local$_blocksarray. No inheritance, no template hierarchy, just named functions. A few lines of code.{varType Type $var}→/** @var Type $var */PHPDoc. The template is type-safe, the generated PHP too, and most importantly, PHPStan is happy.
I also added a {use Tracy\Helpers} tag, which Latte doesn't
have out of the box. A few lines of code with a node class and a
print() method that emits a PHP use at the top of the
generated file.
Bonus: The Dedent Feature for Clean Output
Since version 2.12, Tracy can export exceptions as plain text (markdown) that you can hand to your AI agent so it can fix the bug. No HTML, no CSS, just markdown aligned to the left margin.
The template uses tags like {define}, {foreach},
{if}. I want it indented according to the structure of
those tags, otherwise I won't find anything in it a few months from now. But
the output has to be clean markdown aligned to the left margin, where every
character before a line means something specific (# is a heading,
and so on). So I need an indented source and at the same time an unindented
output.
How to pull that off? You either align the source to the left margin and give
up the visual structure of nested tags, or you keep the structure and wrap the
whole thing in {strip} plus manual {= "\n"} tricks to
chop the indentation out of the output. Both paths are unpleasant.
Fortunately Latte 3.1 brought a feature called Dedent. At compile time, Latte automatically strips the common indentation prefix inside each pair tag. The source mirrors the structure of the control tags, the output is clean. For markdown reports, where every character of indentation matters, it's exactly the tool I needed.
An excerpt from the real agent.latte template in Tracy:
{foreach Helpers::getExceptionChain($exception) as $i => $ex}
{if $i === 0}
# {$title}: {$ex->getMessage()}{$code}
{else}
## Caused by: {$title}: {$ex->getMessage()}{$code}
{/if}
in {$ex->getFile()}:{$ex->getLine()}
{include renderSource $ex->getFile(), $ex->getLine()}
{/foreach}
And the corresponding output, aligned to the left margin, the way markdown is meant to be:
# Exception: The my exception #123
in /path/to/file.php:42
40 | function third($arg1)
41 | {
► 42 | throw new Exception('The my exception', 123);
43 | }
Indented source, unindented output. A problem that would elsewhere force a choice between two ugly solutions is a breeze.
Benefits: Security and Type-Safety
What's changed, then? Tracy's templates are shorter, more readable,
type-safe (thanks to {varType} and phpDoc comments that PHPStan and
IDEs understand) and they can't contain XSS bugs. The generated
.phtml files are flat imperative PHP with no dependencies. Tracy's
composer.json stayed exactly the same. No one on the outside
notices anything has changed.
And for me as the author? Instead of writing htmlspecialchars
for the four-hundredth time, I write {$x}. That's all I ever
wanted from a templating system.
Latte isn't physically present in Tracy. And yet you can feel it in the
resulting code. In the fact that I no longer have to write a third
<?php endif ?> in a row. Latte as a ghost in the machine.
Like the best framework I know: the one that simply isn't visible in the
resulting code. 🙂
Sign in to submit a comment