Latte jako duch ve stroji: kompilátor šablon pro Tracy
Šablony Tracy byly vždycky ručně psané .phtml
stránky plné htmlspecialchars a vnořených if.
Přiznám se, že mě to dlouho štvalo. A pak mě napadla kacířská
myšlenka: co kdyby je psalo Latte? Jenže Latte v Tracy? Overkill! Ačkoliv…
šlo by to, ale jinak, než by člověk čekal.
Tracy je debugger. Musí běžet v každém PHP projektu, který si ji do
composer.json napíše. Ve startupech, v legacy aplikacích, na
shared hostingu z roku 2014. Žádná tlustá runtime závislost na
šablonovacím systému tam nemá co dělat. Tečka.
Jenže ty šablony. Pokud jste si někdy psali vlastní panel do Tracy baru,
víte, o čem mluvím. Pár polí v tabulce, dynamické CSS třídy podle
stavu, podmíněné atributy, a najednou se vám pod rukama rodí 35 řádků
ošklivého .phtml:
<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>
Spočítejte si v té šabloně <?php, ?> a
htmlspecialchars. A pak si představte, že do ní budete za půl
roku přidávat čtvrtý sloupec. Klasická radost. A o tom, že každá
zapomenutá htmlspecialchars je potenciální XSS, ani nemluvím.
V debuggeru. Krása.
Latte místo surového PHP
Jednoho večera mě napadlo, jak by ta samá tabulka vypadala v 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>
Polovina znaků. Latte ví, v jakém HTML kontextu se zrovna
nachází, a escapuje podle toho: v textu jinak než v atributu,
v atributu jinak než uvnitř <script>. Žádný
htmlspecialchars ručně. Žádné zapomenutí. Žádná možnost
se splést.
A všimněte si atributu title. Tohle je ukázka Smart
HTML Attributes z Latte 3.1: když hodnota vrátí null,
atribut se do výstupu vůbec nevypíše.
Krásné. A absolutně nepřijatelné. Tracy nesmí mít Latte jako runtime závislost.
Latte jako build-time kompilátor
A pak to přišlo. Latte se přece nemusí stát součástí Tracy. Stačí, když bude součástí buildu Tracy.
Princip je triviální. Šablony se píšou v .latte. Při
buildu (lokálně, před commitem, klidně i v CI) se z nich vygenerují
statické .phtml soubory. Ty se commitnou do repa. Tracy je při
běhu jen requiruje. Nic víc. V composer.json se Latte neobjeví.
Vývojář dostane krásné šablony, programátor, který Tracy nasadí,
dostane stejně lehkou knihovnu jako dřív.
Latte v této roli nic nerenderuje. Šablonu rozebere a místo ní vyrobí PHP, které dělá totéž. Kompilátor, ne engine. Když mi tohle docvaklo, dílky do sebe zapadly.
Jak funguje kompilace šablon
Latte má naštěstí čisté API. Extension pointy, vlastní uzly v AST, parametrizovatelný printer. Stačí napsat tři věci:
- vlastní engine, který místo třídy dědící z runtime šablony vygeneruje flat standalone PHP: klasický echo-based skript, který nepotřebuje žádný objektový kontext;
- vlastní escaper, který v generovaném PHP volá
Tracy\Helpers::escapeHtml()místoLatte\Runtime\Filters::escapeHtmlText(). Důvod je nasnadě: kdyby vygenerovaný kód volal Latte runtime, byl by zase závislý na Latte; - vlastní extension, která zaregistruje jen ty tagy, které v šablonách reálně používám.
Většinu tagů jsem mohl převzít z Latte tak, jak jsou.
{if}, {foreach}, {var},
{do}, {=...}. Ty existují kompletně na úrovni AST a
printer je přeloží do PHP if, foreach a echo.
Žádný runtime nepotřebují.
Jenže pak jsou v Latte tagy, které jsou systémově propojené s runtime,
protože Latte normálně generuje šablonovou třídu a ony s tou
třídou pracují. {include} umí dědičnost a přepisování
bloků. {define} generuje bloky vázané na šablonovou třídu.
{try} to dělá přes output buffering.
Tohle všechno se v Tracy nehodí. Žádné šablonové třídy, žádná hierarchie, žádný OB. Tak jsem si tyhle tagy přepsal. A teď přijde ta hlavní pointa: nepsal jsem vlastní parser, vlastní AST, vlastní traverser. Napsal jsem jen vlastní implementaci uzlu, který se chová tak, jak potřebuju:
{include 'file.phtml'}→ prostérequire __DIR__ . '/file.phtml'. Tečka.{define name}...{/define}+{include name}→ bloky jako closures v lokální$_blocksarray. Žádná dědičnost, žádná hierarchie šablon, jen pojmenované funkce. Pár řádků kódu.{varType Type $var}→/** @var Type $var */PHPDoc. Šablona je type-safe, vygenerované PHP taky a hlavně PHPStan je spokojený.
Přidal jsem si ještě tag {use Tracy\Helpers}, která v Latte
standardně neexistuje. Pár řádků kódu s třídou uzlu s metodou
print(), která vypíše PHP use na začátek
vygenerovaného souboru.
Bonus: feature Dedent pro čistý kód
Tracy od verze 2.12 umí výjimky exportovat jako čistý plaintext (markdown), na který pustíte svého AI agenta a ten chyby opraví. Žádné HTML, žádné CSS, jen markdown zarovnaný od levého okraje.
Šablona obsahuje řadu značek jako {define},
{foreach}, {if}. Chci ji mít odsazenou podle
struktury těch tagů, jinak v ní za pár měsíců nic nenajdu. Jenže
výstup musí být čistý markdown zarovnaný k levému okraji, kde každý
znak před řádkem znamená něco konkrétního (# je nadpis
apod.). Takže potřebuju odsazený zdroj a zároveň neodsazený
výstup.
Jak na to? Buď zdroj zarovnáte od levého okraje a vzdáte se vizuální
struktury vnořených tagů, nebo strukturu zachováte a celé to obalíte do
{strip} a ručními {= "\n"} triky, abyste z výstupu
odřízli odsazení. Obě cesty jsou nepříjemné.
Naštěstí přišla v Latte 3.1 fíčura zvaná Dedent. Latte při kompilaci automaticky odřízne společný prefix odsazení uvnitř každého párového tagu. Zdroj kopíruje strukturu řídicích tagů, výstup je čistý. Pro markdown reporty, kde záleží na každém znaku odsazení, je to přesně ten nástroj, který jsem potřeboval.
Ukázka z reálné šablony agent.latte pro 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}
A odpovídající výstup, zarovnaný od levého okraje, jak má markdown být:
# 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 | }
Odsazený zdroj, neodsazený výstup. Z problému, který by jinde vedl k volbě jednoho ze dvou ošklivých řešení, je brnkačka.
Výhody: Bezpečnost a type-safety
Co se tedy změnilo? Šablony Tracy jsou kratší, čitelnější, type-safe
(díky {varType} a phpDoc komentářům, kterým rozumí PHPStan
i IDE) a nemůžou v nich být XSS chyby. Generované .phtml jsou
plochá imperativní PHP bez závislostí. Tracy composer.json
zůstal úplně stejný. Nikdo zvenčí nepozná, že se něco změnilo.
A pro mě jako autora? Místo psaní htmlspecialchars po
čtyřsté píšu {$x}. To je vše, co od šablonovacího
systému chci.
Latte v Tracy fyzicky není. A přesto je ve výsledném kódu cítit.
V tom, že už nemusím psát třetí <?php endif ?> za
sebou. Latte jako duch ve stroji. Jako ten nejlepší framework, jaký znám:
ten, který ve výsledném kódu prostě není vidět. 🙂
Chcete-li odeslat komentář, přihlaste se