Latte jako duch ve stroji: kompilátor šablon pro Tracy

před 3 hodinami od David Grudl  

Š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ísto Latte\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í $_blocks array. Žá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. 🙂

David Grudl Je specialista na umělou inteligenci a webové technologie, tvůrce Nette Framework a dalších populárních open-source projektů. Publikuje na blozích Uměligence, phpFashion a La Trine. Školí práci s AI nástroji a moderuje pořad Tech Guys. Umělou inteligenci se snaží přiblížit lidem srozumitelným způsobem. Je kreativní a má smysl pro praktické využití technologií.