Laravel-застосунки часто потребують однакових змін у кожному Blade-файлі - наприклад, перейменування компонента або нормалізації директиви. Однорядковий sed замінює кожен збіг, включно з тими, що всередині рядків, коментарів та атрибутів, які ви не хотіли міняти. Forte, написаний John Koster, парсить .blade.php у типізоване синтаксичне дерево, дає змогу запитувати дерево та рендерить переписаний шаблон назад.
Koster також є автором prettier-plugin-blade. Версія 3 цього форматера працює на Forte.
Використовуйте Forte, коли потрібно з'ясувати, що містять ваші в'юшки, застосувати одну зміну до сотень файлів або зламати білд, коли шаблон порушує конвенції вашої команди.
Встановлення
Forte потребує PHP 8.2, розширення dom та Laravel 10-13:
composer require fortephp/forte
Сервіс-провайдер реєструється автоматично, тому фасад Forte доступний одразу.
Парсинг
Forte::parse() приймає рядок, Forte::parseFile() приймає шлях:
use Forte\Facades\Forte;
$doc = Forte::parse('<div class="mt-4">Hello, {{ $name }}!</div>');
$doc = Forte::parseFile('resources/views/welcome.blade.php');
Обидва методи проходять через лексер, який перетворює вихідний код у токени, та tree builder, який збирає вузли. Document зберігає результат.
Якщо тег не закритий або @if без @endif, парсер записує діагностику до документа і повертає часткове дерево. Ви можете запитувати та переписувати це дерево, як і будь-яке інше. Два зламані в'юшки у застосунку з чотирма сотнями в'юшок створюють дві діагностики, а решта 398 парсяться нормально.
Якщо розпарсити файл і відрендерити його назад без змін, ви отримаєте ті самі байти, включно з пробілами.
Запити до дерева
Методи запитів повертають ліниві колекції, тому ви можете фільтрувати та трансформувати їх так само, як будь-яку іншу Laravel-колекцію:
$forms = $doc->queryElements('form');
$conditionals = $doc->queryBlockDirectives(['if', 'unless']);
$components = $doc->queryComponents(['x-alert', 'livewire:*']);
Для одного відомого вузла є прямі пошуки:
$navigation = $doc->elementById('primary-navigation');
$hasHead = $doc->hasElement('head');
isDynamic() повідомляє, чи значення прийшло з прив'язаного атрибута, а attributeTokens('class') розбиває список класів на масив:
$form = $doc->firstElement('form');
$method = $form?->attribute('method');
$isDynamic = $method?->isDynamic() ?? false;
$classes = $form?->attributeTokens('class') ?? [];
Forte будує DOMDocument з дерева і запускає вираз через PHP DOMXPath, що і покриває вимогу ext-dom. Blade-конструкції стають елементами у просторі імен forte, тому блоки @if - це forte:if, а ехо - forte:echo:
$divs = $doc->xpath('//div[@class]')->get();
$conditionals = $doc->xpath('//forte:if')->get();
Збіги повертаються як вузли Forte, а не об'єкти DOMElement, тому ви можете передати результат запиту до перезапису. Пошук кожного <a> всередині <nav>, що не має href, - це один вираз замість рекурсивного проходу.
Переписування
apply(), rewrite() та rewriteWith() кожен повертають новий Document і залишають оригінал без змін, тому ви можете зберегти обидва і порівняти їх.
rewriteWith() обробляє разові зміни через замикання. Колбек отримує NodePath, а не сам вузол:
use Forte\Rewriting\NodePath;
$newDoc = $doc->rewriteWith(function (NodePath $path) {
if ($path->isTag('a') && str_starts_with($path->getAttribute('href') ?? '', 'http')) {
$path->setAttribute('target', '_blank');
$path->setAttribute('rel', 'noopener noreferrer');
}
});
echo $newDoc->render();
NodePath надає доступ до батьківського елемента, сусідів, предків та глибини вузла, на який він вказує, а також до методів getAttribute(), setAttribute(), removeAttribute(), addClass(), renameTag(), replaceWith(), remove(), insertBefore() та insertAfter(). skipChildren() і stopTraversal() завершують обхід достроково, коли ви знайшли потрібний вузол.
Forte ставить зміни у чергу, а не застосовує їх одну за одною, тому прохід по великому шаблону створює один новий документ замість одного на кожну зміну.
Для чогось довшого за замикання, напишіть візитор:
use Forte\Rewriting\Visitor;
use Forte\Rewriting\NodePath;
class NormalizeAlerts extends Visitor
{
public function enter(NodePath $path): void
{
if (! $path->isTag('div') || ! $path->hasAttribute('data-alert')) {
return;
}
$level = $path->getAttribute('data-alert') ?? 'info';
$path->renameTag('x-alert');
$path->setAttribute('type', $level);
$path->removeAttribute('data-alert');
}
}
enter() запускається до того, як відвідуються діти вузла, а leave() - після. Більшість проходів потребують лише enter(). Використовуйте leave(), коли зміна залежить від того, що сталося з дітьми, наприклад, розгортання елемента після того, як його вміст було переписано.
Є також RewriteBuilder для декларативної версії: виберіть вузли через XPath, потім поставте у чергу мутації для застосування до збігів.
Створення нових вузлів
Переписування часто потребують нового вузла для заміни. Builder створює його:
use Forte\Rewriting\Builders\Builder;
Builder::element('div')->class('wrapper')->text('Hello');
Builder::directive('if', '($show)');
Builder::echo('$name');
Передайте результат до replaceWith(), insertBefore() або insertAfter().
Аудит, кодмоди та CI-перевірки
З'ясування вмісту в'юшок
Перед видаленням компонента знайдіть кожну в'юшку, що все ще його рендерить:
use Forte\Facades\Forte;
use Illuminate\Support\Facades\File;
foreach (File::allFiles(resource_path('views')) as $file) {
if (! str_ends_with($file->getFilename(), '.blade.php')) {
continue;
}
$uses = Forte::parseFile($file->getPathname())
->queryComponents(['x-alert'])
->count();
if ($uses > 0) {
echo "{$file->getRelativePathname()}: {$uses}\n";
}
}
grep -rc 'x-alert' resources/views відповідає на схоже питання в один рядок, але рахує згадки в коментарях, у рядках @php та в атрибуті class="x-alert-icon" поряд із справжніми. Підрахунок вище - це кількість разів, коли компонент рендериться.
Виконання однієї зміни в сотнях в'юшок
Додавання loading="lazy" до кожного <img>, що не має атрибута loading, - це прохід, який можна запустити, переглянути як diff і перезапустити після коригування:
use Forte\Facades\Forte;
use Forte\Rewriting\NodePath;
use Illuminate\Support\Facades\File;
foreach (File::allFiles(resource_path('views')) as $file) {
if (! str_ends_with($file->getFilename(), '.blade.php')) {
continue;
}
$doc = Forte::parseFile($file->getPathname());
$updated = $doc->rewriteWith(function (NodePath $path) {
if ($path->isTag('img') && ! $path->hasAttribute('loading')) {
$path->setAttribute('loading', 'lazy');
}
});
file_put_contents($file->getPathname(), $updated->render());
}
<img>, написаний всередині коментаря, рядка або блоку @php, парситься як інший тип вузла, тому isTag('img') для нього повертає false. Зробити це правильно з регулярним виразом вимагає більше уваги, ніж решта роботи.
Якщо прохід був неправильним, виправте скрипт, запустіть git restore resources/views і спробуйте знову.
Провал білда при порушенні конвенції
POST-форма без @csrf - це один вираз, тому перевірка вміщається в тест:
use Forte\Facades\Forte;
use Illuminate\Support\Facades\File;
test('every POST form has a CSRF token', function () {
$offenders = [];
foreach (File::allFiles(resource_path('views')) as $file) {
if (! str_ends_with($file->getFilename(), '.blade.php')) {
continue;
}
$missing = Forte::parseFile($file->getPathname())
->xpath('//form[@method="POST"][not(.//forte:csrf)]')
->count();
if ($missing > 0) {
$offenders[] = $file->getRelativePathname();
}
}
expect($offenders)->toBeEmpty();
});
Цей вираз читається зліва направо: кожна <form> з method="POST", зберігаючи ті, що не мають @csrf десь всередині. .// - це частина "десь всередині", тому форма, чий @csrf знаходиться в сусідній формі, все ще рахується як порушник.
@foreach, чия перша дитина не має wire:key, також є одним виразом:
$missingKeys = $doc->xpath('//forte:foreach[*[1][not(@*[name()="wire:key"])]]')->count();
//forte:foreach знаходить кожен блок @foreach. *[1] - це перший дочірній елемент цього блоку. not(@*[name()="wire:key"]) зберігає блоки, чия перша дитина не має атрибута wire:key. Форма @*[name()="..."] потрібна, оскільки XPath читає двокрапку в wire:key як роздільник простору імен.
Обидва вирази є тестами у власному тестовому наборі Forte. Другий залежить від вкладеності та від того, яка дитина йде першою.
Коли регулярного виразу достатньо
Для одного перейменування в тридцяти в'юшках sed або структурний пошук вашої IDE плюс ретельне читання diff - це менше роботи, ніж написання візитора. Те саме стосується зміни, яку ви робите один раз і більше ніколи не перевіряєте.
Forte варто налаштовувати у цих випадках:
- Правило залежить від структури: "всередині форми", "перша дитина", "вкладено в
@foreach". Grep знаходить рядки, тому не може виразити це.
- Зміна зачіпає стільки файлів, що читання всього diff вручну займає більше часу, ніж написання проходу.
- Перевірка запускається на кожному коміті, де збіг всередині коментаря або рядка ламає білд без причини.
Chisel та Reload
Два інші пакети побудовані на Forte, і ви можете використовувати обидва без написання візитора. Chisel - це prettier-plugin-blade v3, переписаний на новому парсері; проєкт повідомляє, що він форматує складні реальні шаблони в 140 разів швидше за попередню версію. Потребує Node 18 або новіше:
npm i -D prettier prettier-plugin-blade@^3
Форматування Blade тепер має більше опцій, включно з власною підтримкою Blade у Laravel Pint та format-on-save у PhpStorm.
Reload - це Vite-плагін, який патчить зміни Blade на сторінку без повного оновлення. Його документація називає його експериментальним, і він повертається до повного оновлення після max_patches_before_reload інкрементальних патчів:
composer require fortephp/reload --dev
Він стежить за resources/views/**/*.blade.php і інструментує елементи, компоненти, директиви та включення. Опція refresh у laravel-vite-plugin вже перезавантажує сторінку при зміні Blade-файлу. Reload патчить DOM замість цього.
Forte має ліцензію MIT і наразі у версії v1.1.0. Вихідний код на GitHub, документація та інтерактивний playground на fortephp.com.