Доброго времени суток уважаемые читатели хабра. С каждым годом в веб разработке появляется все больше разнообразных решений которые используют модульный подход и упрощают разработку и редактирование кода. В данной статье я предлагаю вам свой взгляд на то, какими могут быть переиспользуемые front-end блоки (для проектов с бэкендом на php) и предлагаю пройти все шаги от идеи до реализации вместе со мной. Звучит интересно? Тогда добро пожаловать под кат.
Предисловие
Представлюсь - я молодой веб разработчик с опытом работы 5 лет. Последний год я работаю на фрилансе и большая часть текущих проектов связана с WordPress. Несмотря на различую критику CMS в общем и WordPress в часности, я считаю сама архитектура WordPress это довольно удачное решение, хотя конечно не без определенных недостатков. И один из них на мой взгляд это шаблоны. В крайних обновлениях сделаны большие шаги чтобы это исправить, и Gutenberg в целом становится мощным инструментом, однако к сожалению в большинстве тем продолжается каша в шаблонах, стилях и скриптах, которая делает редактирование чего-либо крайне болезненным, а переиспользование кода зачастую невозможным. Именно эта проблема и подтолкнуло меня к идее своего мини фреймворка (читай пакета, но поскольку он будет предъявлять требования к структуре, то гордо назовем мини фреймворком), который бы организовывал структуру и позволял переиспользовать блоки.
Реализация будет в виде composer пакета, который можно будет использовать в совершенно различных проектах, без привязки к WordPress.
Мотивом написать данную статью было желание поделится решением для организации модульных блоков, а также желание читателя хабра написать свою статью, что сродни желанию создать свой пакет, которое порой возникает у начинающих использовать готовые пакеты composer или npm.
Как можно заключить из текста выше, это моя первая статья на хабре, по-этому просьба не бросать помидоры не судить строго.
Постановка задачи
Понятие блок ниже будет по сути тем же понятием что и блок в BEM методологии, т.е. это будет группа html/js/css кода которая будет представлять одну сущность.
Генерировать html и управлять зависимостями блоков мы будем через php, что говорит о том, что наш пакет будет подходить для проектов с бекендом на php. Также условимся на берегу что, не вдаваясь в споры, не будем поддаваться влиянию новомодных вещей, таких как css-in-js или bem-json и будем придерживаться эль-классико классического подхода, т.е. предполагать что html, css и js это разные файлы.
Теперь давайте сформулируем наши основные требования к будущему мини-фреймворку:
Обеспечить структуру блоков
Предоставить поддержку наследования (расширения) блоков
Предоставить возможность использовать блок в блоке и соответственно поддержку зависимости ресурсов одного блока от ресурсов других блоков
Структура мини фреймворка
Как условились выше, такие ресурсы как css и js всегда будут в виде обычных файлов, т.е. это будут .js и .css или .min.css и .min.js в случае использования препроцесссоров и сборщиков (как webpack например). Для вставки данных в html код мы будем использовать шаблонизатор Twig (для тех кто не знаком ссылка). Кто-то может заметить, что Php и сам по себе хороший шаблонизатор, не будем вдаваться в споры, кроме доводов указанных на главной странице проекта Twig, отмечу важный для меня пункт, то что он дисциплинирует, т.е. заставляет отделять обработку от вывода и подготавливать переменные заранее, и в данном случае мы будем использовать его.
Теперь давайте продумаем структуру нашего мини фреймворка более детально.
Блок
Каждый блок будет состоять из:
Статических ресурсов (css/js/twig)
Класса модели (его поля мы будет предоставлять как данные для twig шаблона)
Класса контролера (он будет отвечать за наши ресурсы, их зависимости друг от друга и связывать модель с twig шаблоном)
Вспомогательные классы : Класс Settings (будет содержать путь к блокам, их пространство имен и т.д.), класс обертка для Twig пакета
Blocks класс
Связующий класс, который :
будет содержать вспомогательные классы (Settings, Twig)
предоставлять функцию рендера блока
содержать список использованных блоков, чтобы иметь возможность получить их ресурсы (css/js)
автоматически загружать все контроллеры (небольшой задел на будущее, это немного выходит за рамки текущей статьи, скажу просто что необходимо для тестов и возможности расширения)
Требования к блокам
Теперь когда мы определились со структурой пришло время зажечь оправдать слово фреймворк в названии для нашего пакета, а именно – указать требования к коду наших блоков:
php 7.4+
Все блоки должны иметь одну родительскую директорию
Классы моделей и контроллеров должны иметь PSR-4 совместимое пространство имен с автозагрузчиком (PSR-4 де факто стандарт, если вы используете автозагрузчик от composer, т.е. указываете autoload/psr4 директиву в вашем composer.json то ваш проект уже соответствует этому требованию)
Соглашение об именах:
Имя контроллера должно содержать ‘C’ суффикс
Класс модели должен иметь то же пространство имен и то же имя (без суффикса) что и соответствующих контроллер
Имена ресурсов должны соответствовать имени контроллера, но с данными отличиями:
Без суффикса контроллера
Верблюжья нотация в имени должны быть заменена на тире (CamelCase = camel-case)
Нижнее подчеркивание в имени должно быть заменено на тире (just_block = just-block)
Таким образом по правилам выше имя ресурса с контроллером ‘Block_Theme_MainC’ будет ‘block—theme--main’
Реализация
Пришло время перейти к реализации нашей идеи, т.е. к коду.
Ниже части реализации (классы) будут в формате : текстовое описание, код реализации и код тестов. Согласен с людьми, которые говорят что тесты есть лучшая документация, однако к своему стыду я начал использовать их в своих проектах недавно, по-этому не смотря на мои старания их имена или структура могут повергнуть в шок сбивать с толку, просьба не принимать близко к сердцу.
FieldsReader
Все наша магия при работе с моделями и контроллерами будет строится на функции ‘get_class_vars’ которая предоставит нам имена полей класса и на ‘ReflectionProperty’ классе, который предоставит нам информацию об этих полях, такую как видимость поля (protected/public) и его тип. Мы будем собирать информацию только о protected полях.
Также упростим дальнейшую разработку, добавив автоинициализацию стандартных полей значением по умолчанию, это избавит нас от необходимости инициализировать их в конструкторе вручную, что при большом количестве блоков сэкономит наше время.
FieldsReader.php
<?php declare(strict_types=1); namespace LightSource\FrontBlocksFramework; use Exception; use ReflectionProperty; abstract class FieldsReader { private array $fieldsInfo; public function __construct() { $this->fieldsInfo = []; $this->readFieldsInfo(); $this->autoInitFields(); } final protected function getFieldsInfo(): array { return $this->fieldsInfo; } protected function getFieldType(string $fieldName): ?string { $fieldType = null; try { // used static for child support $property = new ReflectionProperty(static::class, $fieldName); } catch (Exception $ex) { return $fieldType; } if (! $property->isProtected()) { return $fieldType; } return $property->getType() ? $property->getType()->getName() : ''; } private function readFieldsInfo(): void { $fieldNames = array_keys(get_class_vars(static::class)); foreach ($fieldNames as $fieldName) { $fieldType = $this->getFieldType($fieldName); // only protected fields if (is_null($fieldType)) { continue; } $this->fieldsInfo[$fieldName] = $fieldType; } } private function autoInitFields(): void { foreach ($this->fieldsInfo as $fieldName => $fieldType) { // ignore fields without a type if (! $fieldType) { continue; } $defaultValue = null; switch ($fieldType) { case 'int': case 'float': $defaultValue = 0; break; case 'bool': $defaultValue = false; break; case 'string': $defaultValue = ''; break; case 'array': $defaultValue = []; break; } try { if ( is_subclass_of($fieldType, Model::class) || is_subclass_of($fieldType, Controller::class) ) { $defaultValue = new $fieldType(); } } catch (Exception $ex) { $defaultValue = null; } // ignore fields with a custom type (null by default) if (is_null($defaultValue)) { continue; } $this->{$fieldName} = $defaultValue; } } }
FieldsReaderTest.php
<?php declare(strict_types=1); namespace LightSource\FrontBlocksFramework\Tests\unit; use Codeception\Test\Unit; use LightSource\FrontBlocksFramework\Controller; use LightSource\FrontBlocksFramework\FieldsReader; use LightSource\FrontBlocksFramework\Model; class FieldsReaderTest extends Unit { public function testReadProtectedField() { $fieldsReader = new class extends FieldsReader { protected $loadedField; public function __construct() { parent::__construct(); } public function getFields() { return $this->getFieldsInfo(); } }; $this->assertEquals( [ 'loadedField' => '', ], $fieldsReader->getFields() ); } public function testIgnoreReadPublicField() { $fieldsReader = new class extends FieldsReader { public $unloadedField; public function __construct() { parent::__construct(); } public function getFields() { return $this->getFieldsInfo(); } }; $this->assertEquals( [ ], $fieldsReader->getFields() ); } public function testIgnoreReadPrivateField() { $fieldsReader = new class extends FieldsReader { private $unloadedField; public function __construct() { parent::__construct(); } public function getFields() { return $this->getFieldsInfo(); } }; $this->assertEquals( [ ], $fieldsReader->getFields() ); } public function testReadFieldWithType() { $fieldsReader = new class extends FieldsReader { protected string $loadedField; public function __construct() { parent::__construct(); } public function getFields() { return $this->getFieldsInfo(); } }; $this->assertEquals( [ 'loadedField' => 'string', ], $fieldsReader->getFields() ); } public function testReadFieldWithoutType() { $fieldsReader = new class extends FieldsReader { protected $loadedField; public function __construct() { parent::__construct(); } public function getFields() { return $this->getFieldsInfo(); } }; $this->assertEquals( [ 'loadedField' => '', ], $fieldsReader->getFields() ); } //// public function testAutoInitIntField() { $fieldsReader = new class extends FieldsReader { protected int $int; public function __construct() { parent::__construct(); } public function getInt() { return $this->int; } }; $this->assertTrue(0 === $fieldsReader->getInt()); } public function testAutoInitFloatField() { $fieldsReader = new class extends FieldsReader { protected float $float; public function __construct() { parent::__construct(); } public function getFloat() { return $this->float; } }; $this->assertTrue(0.0 === $fieldsReader->getFloat()); } public function testAutoInitStringField() { $fieldsReader = new class extends FieldsReader { protected string $string; public function __construct() { parent::__construct(); } public function getString() { return $this->string; } }; $this->assertTrue('' === $fieldsReader->getString()); } public function testAutoInitBoolField() { $fieldsReader = new class extends FieldsReader { protected bool $bool; public function __construct() { parent::__construct(); } public function getBool() { return $this->bool; } }; $this->assertTrue(false === $fieldsReader->getBool()); } public function testAutoInitArrayField() { $fieldsReader = new class extends FieldsReader { protected array $array; public function __construct() { parent::__construct(); } public function getArray() { return $this->array; } }; $this->assertTrue([] === $fieldsReader->getArray()); } public function testAutoInitModelField() { $testModel = new class extends Model { }; $testModelClass = get_class($testModel); $fieldsReader = new class ($testModelClass) extends FieldsReader { protected $model; private $testClass; public function __construct($testClass) { $this->testClass = $testClass; parent::__construct(); } public function getFieldType(string $fieldName): ?string { return ('model' === $fieldName ? $this->testClass : parent::getFieldType($fieldName)); } public function getModel() { return $this->model; } }; $actualModelClass = $fieldsReader->getModel() ? get_class($fieldsReader->getModel()) : ''; $this->assertEquals($actualModelClass, $testModelClass); } public function testAutoInitControllerField() { $testController = new class extends Controller { }; $testControllerClass = get_class($testController); $fieldsReader = new class ($testControllerClass) extends FieldsReader { protected $controller; private $testClass; public function __construct($testControllerClass) { $this->testClass = $testControllerClass; parent::__construct(); } public function getFieldType(string $fieldName): ?string { return ('controller' === $fieldName ? $this->testClass : parent::getFieldType($fieldName)); } public function getController() { return $this->controller; } }; $actualModelClass = $fieldsReader->getController() ? get_class($fieldsReader->getController()) : ''; $this->assertEquals($actualModelClass, $testControllerClass); } public function testIgnoreInitFieldWithoutType() { $fieldsReader = new class extends FieldsReader { protected $default; public function __construct() { parent::__construct(); } public function getDefault() { return $this->default; } }; $this->assertTrue(null === $fieldsReader->getDefault()); } }
Model
Данный класс по сути лишь небольшая обертка для класса FieldsReader, который содержит поле ‘isLoaded’, что отвечает за состояние модели, оно пригодится нам когда мы будем работать с twig, и функции ‘getFields’, которая возвращает массив со значениями protected полей, в котором ключи это их имена.
Model.php
<?php declare(strict_types=1); namespace LightSource\FrontBlocksFramework; abstract class Model extends FieldsReader { private bool $isLoaded; public function __construct() { parent::__construct(); $this->isLoaded = false; } final public function isLoaded(): bool { return $this->isLoaded; } public function getFields(): array { $args = []; $fieldsInfo = $this->getFieldsInfo(); foreach ($fieldsInfo as $fieldName => $fieldType) { $args[$fieldName] = $this->{$fieldName}; } return $args; } final protected function load(): void { $this->isLoaded = true; } }
ModelTest.php
<?php declare(strict_types=1); namespace LightSource\FrontBlocksFramework\Tests\unit; use Codeception\Test\Unit; use LightSource\FrontBlocksFramework\Model; class ModelTest extends Unit { public function testGetFields() { $model = new class extends Model { protected string $field1; public function __construct() { parent::__construct(); } public function update() { $this->field1 = 'just string'; } }; $model->update(); $this->assertEquals( [ 'field1' => 'just string', ], $model->getFields() ); } }
Controler
Данный класс также как и Model наследует класс FieldsReader, однако имеет и другие важные задачи. Содержит два поля – модель и массив ‘external’, который пригодится нам далее при работе с twig шаблоном.
Статический метод getResourceInfo позволяет получить информацию о статических ресурсах данного блока (twig,css,js) , такую как имя ресурса или относительный путь к нему (мы можем получить это из имени и пространства имен контроллера благодаря соблюдению требований выше).
Метод getTemplateArgs будет возвращать данные для twig шаблона, это все protected поля соответствующей модели (без префикса ‘_’ если есть) и два дополнительных поля, _template и _isLoaded, первое будет содержать путь к шаблону, а второе отображать состояние модели. Также в этом методе мы реализуем возможность использовать блок в блоке (т.е. иметь класс Model в другом классе Model как поле) - мы соединяем поля контроллера и поля соответствующей модели по имени : т.е. если каждому полю с типом контроллер мы находим соответствующее поле в модели (с типом модель), то мы инициализируем поле контроллер моделью и вызываем метод getTemplateArgs у этого контроллера, получая таким образом все необходимую информацию для отображения этого вложенного блока.
Метод getDependencies на основании наших полей с типом контроллер рекурсивно (с обходом всех подзависимостей) возвращает нам уникальный (т.е. без повторений) список используемых классов-контроллеров, что помогает нам реализовать возможность зависимости ресурсов одного блока от другого.
Также отмечу отдельный момент, автоматическую инициализацию поля модели в конструкторе контроллера, т.е. если существует класс с таким же именем как у контроллера, но без суффикса (смотри требования выше), то это поле будет инициализировано объектом этого класса. Это позволит избежать лишнего кода в дальнейшем (т.е. создания модели отдельно от контроллера) и необходимо для расширения (это выходит за рамки нашей статьи).
Controller.php
<?php declare(strict_types=1); namespace LightSource\FrontBlocksFramework; use Exception; abstract class Controller extends FieldsReader { public const TEMPLATE_KEY__TEMPLATE = '_template'; public const TEMPLATE_KEY__IS_LOADED = '_isLoaded'; private ?Model $model; private array $external; public function __construct(?Model $model = null) { parent::__construct(); $this->model = $model; $this->external = []; $this->autoInitModel(); } final public static function getResourceInfo(Settings $settings, string $controllerClass = ''): array { // using static for children support $controllerClass = ! $controllerClass ? static::class : $controllerClass; // e.g. $controllerClass = Example/Theme/Main/Example_Theme_Main_C $resourceInfo = [ 'resourceName' => '',// e.g. example--theme--main 'relativePath' => '',// e.g. Example/Theme/Main 'relativeResourcePath' => '', // e.g. Example/Theme/Main/example--theme--main ]; $controllerSuffix = Settings::$controllerSuffix; // e.g. Example/Theme/Main/Example_Theme_Main $relativeControllerNamespace = $settings->getBlocksDirNamespace() ? str_replace($settings->getBlocksDirNamespace() . '\\', '', $controllerClass) : $controllerClass; $relativeControllerNamespace = substr( $relativeControllerNamespace, 0, mb_strlen($relativeControllerNamespace) - mb_strlen($controllerSuffix) ); // e.g. Example_Theme_Main $phpBlockName = explode('\\', $relativeControllerNamespace); $phpBlockName = $phpBlockName[count($phpBlockName) - 1]; // e.g. example--theme--main (from Example_Theme_Main) $blockNameParts = preg_split('/(?=[A-Z])/', $phpBlockName, -1, PREG_SPLIT_NO_EMPTY); $blockResourceName = []; foreach ($blockNameParts as $blockNamePart) { $blockResourceName[] = strtolower($blockNamePart); } $blockResourceName = implode('-', $blockResourceName); $blockResourceName = str_replace('_', '-', $blockResourceName); // e.g. Example/Theme/Main $relativePath = explode('\\', $relativeControllerNamespace); $relativePath = array_slice($relativePath, 0, count($relativePath) - 1); $relativePath = implode(DIRECTORY_SEPARATOR, $relativePath); $resourceInfo['resourceName'] = $blockResourceName; $resourceInfo['relativePath'] = $relativePath; $resourceInfo['relativeResourcePath'] = $relativePath . DIRECTORY_SEPARATOR . $blockResourceName; return $resourceInfo; } // can be overridden if Controller doesn't have own twig (uses parents) public static function getPathToTwigTemplate(Settings $settings, string $controllerClass = ''): string { return self::getResourceInfo($settings, $controllerClass)['relativeResourcePath'] . $settings->getTwigExtension(); } // can be overridden if Controller doesn't have own model (uses parents) public static function getModelClass(): string { $controllerClass = static::class; $modelClass = rtrim($controllerClass, Settings::$controllerSuffix); return ($modelClass !== $controllerClass && class_exists($modelClass, true) && is_subclass_of($modelClass, Model::class) ? $modelClass : ''); } public static function onLoad() { } final public function setModel(Model $model): void { $this->model = $model; } final protected function setExternal(string $fieldName, array $args): void { $this->external[$fieldName] = $args; } private function getControllerField(string $fieldName): ?Controller { $controller = null; $fieldsInfo = $this->getFieldsInfo(); if (key_exists($fieldName, $fieldsInfo)) { $controller = $this->{$fieldName}; // prevent possible recursion by a mistake (if someone will create a field with self) // using static for children support $controller = ($controller && $controller instanceof Controller && get_class($controller) !== static::class) ? $controller : null; } return $controller; } public function getTemplateArgs(Settings $settings): array { $modelFields = $this->model ? $this->model->getFields() : []; $templateArgs = []; foreach ($modelFields as $modelFieldName => $modelFieldValue) { if (! $modelFieldValue instanceof Model) { $templateArgs[$modelFieldName] = $modelFieldValue; continue; } $modelFieldController = $this->getControllerField($modelFieldName); $modelFieldArgs = []; $externalFieldArgs = $this->external[$modelFieldName] ?? []; if ($modelFieldController) { $modelFieldController->setModel($modelFieldValue); $modelFieldArgs = $modelFieldController->getTemplateArgs($settings); } $templateArgs[$modelFieldName] = Helper::arrayMergeRecursive($modelFieldArgs, $externalFieldArgs); } // using static for children support return array_merge( $templateArgs, [ self::TEMPLATE_KEY__TEMPLATE => static::getPathToTwigTemplate($settings), self::TEMPLATE_KEY__IS_LOADED => ($this->model && $this->model->isLoaded()), ] ); } public function getDependencies(string $sourceClass = ''): array { $dependencyClasses = []; $controllerFields = $this->getFieldsInfo(); foreach ($controllerFields as $fieldName => $fieldType) { $dependencyController = $this->getControllerField($fieldName); if (! $dependencyController) { continue; } $dependencyClass = get_class($dependencyController); // 1. prevent the possible permanent recursion // 2. add only unique elements, because several fields can have the same type if ( ($sourceClass && $dependencyClass === $sourceClass) || in_array($dependencyClass, $dependencyClasses, true) ) { continue; } // used static for child support $subDependencies = $dependencyController->getDependencies(static::class); // only unique elements $subDependencies = array_diff($subDependencies, $dependencyClasses); // sub dependencies are before the main dependency $dependencyClasses = array_merge($dependencyClasses, $subDependencies, [$dependencyClass,]); } return $dependencyClasses; } // Can be overridden for declare a target model class and provide an IDE support public function getModel(): ?Model { return $this->model; } private function autoInitModel() { if ($this->model) { return; } $modelClass = static::getModelClass(); try { $this->model = $modelClass ? new $modelClass() : $this->model; } catch (Exception $ex) { $this->model = null; } } }
ControllerTest.php
<?php declare(strict_types=1); namespace LightSource\FrontBlocksFramework\Tests\unit; use Codeception\Test\Unit; use LightSource\FrontBlocksFramework\{ Controller, Model, Settings }; class ControllerTest extends Unit { private function getModel(array $fields, bool $isLoaded = false): Model { return new class ($fields, $isLoaded) extends Model { private array $fields; public function __construct(array $fields, bool $isLoaded) { parent::__construct(); $this->fields = $fields; if ($isLoaded) { $this->load(); } } public function getFields(): array { return $this->fields; } }; } private function getController(?Model $model): Controller { return new class ($model) extends Controller { public function __construct(?Model $model = null) { parent::__construct($model); } }; } private function getTemplateArgsWithoutAdditional(array $templateArgs): array { $templateArgs = array_diff_key( $templateArgs, [ Controller::TEMPLATE_KEY__TEMPLATE => '', Controller::TEMPLATE_KEY__IS_LOADED => '', ] ); foreach ($templateArgs as $templateKey => $templateValue) { if (! is_array($templateValue)) { continue; } $templateArgs[$templateKey] = $this->getTemplateArgsWithoutAdditional($templateValue); } return $templateArgs; } //// public function testGetResourceInfoWithoutCamelCaseInBlockName() { $settings = new Settings(); $settings->setControllerSuffix('C'); $settings->setBlocksDirNamespace('Namespace'); $this->assertEquals( [ 'resourceName' => 'block', 'relativePath' => 'Block', 'relativeResourcePath' => 'Block/block', ], Controller::GetResourceInfo($settings, 'Namespace\\Block\\BlockC') ); } public function testGetResourceInfoWithCamelCaseInBlockName() { $settings = new Settings(); $settings->setControllerSuffix('C'); $settings->setBlocksDirNamespace('Namespace'); $this->assertEquals( [ 'resourceName' => 'block-name', 'relativePath' => 'BlockName', 'relativeResourcePath' => 'BlockName/block-name', ], Controller::GetResourceInfo($settings, 'Namespace\\BlockName\\BlockNameC') ); } public function testGetResourceInfoWithoutCamelCaseInTheme() { $settings = new Settings(); $settings->setControllerSuffix('C'); $settings->setBlocksDirNamespace('Namespace'); $this->assertEquals( [ 'resourceName' => 'block--theme--main', 'relativePath' => 'Block/Theme/Main', 'relativeResourcePath' => 'Block/Theme/Main/block--theme--main', ], Controller::GetResourceInfo($settings, 'Namespace\\Block\\Theme\\Main\\Block_Theme_MainC') ); } public function testGetResourceInfoWithCamelCaseInTheme() { $settings = new Settings(); $settings->setControllerSuffix('C'); $settings->setBlocksDirNamespace('Namespace'); $this->assertEquals( [ 'resourceName' => 'block--theme--just-main', 'relativePath' => 'Block/Theme/JustMain', 'relativeResourcePath' => 'Block/Theme/JustMain/block--theme--just-main', ], Controller::GetResourceInfo($settings, 'Namespace\\Block\\Theme\\JustMain\\Block_Theme_JustMainC') ); } //// public function testGetTemplateArgsWhenModelContainsBuiltInTypes() { $settings = new Settings(); $model = $this->getModel( [ 'stringVariable' => 'just string', ] ); $controller = $this->getController($model); $this->assertEquals( [ 'stringVariable' => 'just string', ], $this->getTemplateArgsWithoutAdditional($controller->getTemplateArgs($settings)) ); } public function testGetTemplateArgsWhenModelContainsAnotherModel() { $settings = new Settings(); $modelA = $this->getModel( [ 'modelA' => 'just string from model a', ] ); $modelB = $this->getModel( [ 'modelA' => $modelA, 'modelB' => 'just string from model b', ] ); $controllerForModelA = $this->getController(null); $controllerForModelB = new class ($modelB, $controllerForModelA) extends Controller { protected $modelA; public function __construct(?Model $model = null, $controllerForModelA) { parent::__construct($model); $this->modelA = $controllerForModelA; } }; $this->assertEquals( [ 'modelA' => [ 'modelA' => 'just string from model a', ], 'modelB' => 'just string from model b', ], $this->getTemplateArgsWithoutAdditional($controllerForModelB->getTemplateArgs($settings)) ); } public function testGetTemplateArgsWhenControllerContainsExternalArgs() { $settings = new Settings(); $modelA = $this->getModel( [ 'additionalField' => '', 'modelA' => 'just string from model a', ] ); $modelB = $this->getModel( [ 'modelA' => $modelA, 'modelB' => 'just string from model b', ] ); $controllerForModelA = $this->getController(null); $controllerForModelB = new class ($modelB, $controllerForModelA) extends Controller { protected $modelA; public function __construct(?Model $model = null, $controllerForModelA) { parent::__construct($model); $this->modelA = $controllerForModelA; $this->setExternal( 'modelA', [ 'additionalField' => 'additionalValue', ] ); } }; $this->assertEquals( [ 'modelA' => [ 'additionalField' => 'additionalValue', 'modelA' => 'just string from model a', ], 'modelB' => 'just string from model b', ], $this->getTemplateArgsWithoutAdditional($controllerForModelB->getTemplateArgs($settings)) ); } public function testGetTemplateArgsContainsAdditionalFields() { $settings = new Settings(); $model = $this->getModel([]); $controller = $this->getController($model); $this->assertEquals( [ Controller::TEMPLATE_KEY__TEMPLATE, Controller::TEMPLATE_KEY__IS_LOADED, ], array_keys($controller->getTemplateArgs($settings)) ); } public function testGetTemplateArgsWhenAdditionalIsLoadedIsFalse() { $settings = new Settings(); $model = $this->getModel([]); $controller = $this->getController($model); $actual = array_intersect_key( $controller->getTemplateArgs($settings), [Controller::TEMPLATE_KEY__IS_LOADED => '',] ); $this->assertEquals([Controller::TEMPLATE_KEY__IS_LOADED => false,], $actual); } public function testGetTemplateArgsWhenAdditionalIsLoadedIsTrue() { $settings = new Settings(); $model = $this->getModel([], true); $controller = $this->getController($model); $actual = array_intersect_key( $controller->getTemplateArgs($settings), [Controller::TEMPLATE_KEY__IS_LOADED => '',] ); $this->assertEquals([Controller::TEMPLATE_KEY__IS_LOADED => true,], $actual); } public function testGetTemplateArgsAdditionalTemplateIsRight() { $settings = new Settings(); $model = $this->getModel([]); $controller = $this->getController($model); $actual = array_intersect_key( $controller->getTemplateArgs($settings), [Controller::TEMPLATE_KEY__TEMPLATE => '',] ); $this->assertEquals( [ Controller::TEMPLATE_KEY__TEMPLATE => $controller::GetPathToTwigTemplate($settings), ], $actual ); } //// public function testGetDependencies() { $controllerA = $this->getController(null); $controllerB = new class (null, $controllerA) extends Controller { protected $controllerA; public function __construct(?Model $model = null, $controllerA) { parent::__construct($model); $this->controllerA = $controllerA; } }; $this->assertEquals( [ get_class($controllerA), ], $controllerB->getDependencies() ); } public function testGetDependenciesWithSubDependencies() { $controllerA = new class extends Controller { public function getDependencies(string $sourceClass = ''): array { return [ 'A', ]; } }; $controllerB = new class (null, $controllerA) extends Controller { protected $controllerA; public function __construct(?Model $model = null, $controllerA) { parent::__construct($model); $this->controllerA = $controllerA; } }; $this->assertEquals( [ 'A', get_class($controllerA), ], $controllerB->getDependencies() ); } public function testGetDependenciesWithSubDependenciesRecursively() { $controllerA = new class extends Controller { public function getDependencies(string $sourceClass = ''): array { return [ 'A', ]; } }; $controllerB = new class (null, $controllerA) extends Controller { protected $controllerA; public function __construct(?Model $model = null, $controllerA) { parent::__construct($model); $this->controllerA = $controllerA; } }; $controllerC = new class (null, $controllerB) extends Controller { protected $controllerB; public function __construct(?Model $model = null, $controllerB) { parent::__construct($model); $this->controllerB = $controllerB; } }; $this->assertEquals( [ 'A', get_class($controllerA), get_class($controllerB), ], $controllerC->getDependencies() ); } public function testGetDependenciesWithSubDependenciesInOrderWhenSubBeforeMainDependency() { $controllerA = new class extends Controller { public function getDependencies(string $sourceClass = ''): array { return [ 'A', ]; } }; $controllerB = new class (null, $controllerA) extends Controller { protected $controllerA; public function __construct(?Model $model = null, $controllerA) { parent::__construct($model); $this->controllerA = $controllerA; } }; $this->assertEquals( [ 'A', get_class($controllerA), ], $controllerB->getDependencies() ); } public function testGetDependenciesWithSubDependenciesWhenBlocksAreDependentFromEachOther() { $controllerA = new class extends Controller { protected $controllerB; public function setControllerB($controllerB) { $this->controllerB = $controllerB; } }; $controllerB = new class (null, $controllerA) extends Controller { protected $controllerA; public function __construct(?Model $model = null, $controllerA) { parent::__construct($model); $this->controllerA = $controllerA; } }; $controllerA->setControllerB($controllerB); $this->assertEquals( [ get_class($controllerA), ], $controllerB->getDependencies() ); } public function testGetDependenciesWithoutDuplicatesWhenSeveralWithOneType() { $controllerA = $this->getController(null); $controllerB = new class (null, $controllerA) extends Controller { protected $controllerA; protected $controllerAA; protected $controllerAAA; public function __construct(?Model $model = null, $controllerA) { parent::__construct($model); $this->controllerA = $controllerA; $this->controllerAA = $controllerA; $this->controllerAAA = $controllerA; } }; $this->assertEquals( [ get_class($controllerA), ], $controllerB->getDependencies() ); } //// public function testAutoInitModel() { $modelClass = str_replace(['::', '\\'], '_', __METHOD__); $controllerClass = $modelClass . Settings::$controllerSuffix; eval('class ' . $modelClass . ' extends ' . Model::class . ' {}'); eval('class ' . $controllerClass . ' extends ' . Controller::class . ' {}'); $controller = new $controllerClass(); $actualModelClass = $controller->getModel() ? get_class($controller->getModel()) : ''; $this->assertEquals($modelClass, $actualModelClass); } public function testAutoInitModelWhenModelHasWrongClass() { $modelClass = str_replace(['::', '\\'], '_', __METHOD__); $controllerClass = $modelClass . Settings::$controllerSuffix; eval('class ' . $modelClass . ' {}'); eval('class ' . $controllerClass . ' extends ' . Controller::class . ' {}'); $controller = new $controllerClass(); $this->assertEquals(null, $controller->getModel()); } }
Settings
Вспомогательный класс, думаю что не нуждается в комментариях
Settings.php
<?php declare(strict_types=1); namespace LightSource\FrontBlocksFramework; class Settings { public static string $controllerSuffix = 'C'; private string $blocksDirPath; private string $blocksDirNamespace; private array $twigArgs; private string $twigExtension; private $errorCallback; public function __construct() { $this->blocksDirPath = ''; $this->blocksDirNamespace = ''; $this->twigArgs = [ // will generate exception if a var doesn't exist instead of replace to NULL 'strict_variables' => true, // disable autoescape to prevent break data 'autoescape' => false, ]; $this->twigExtension = '.twig'; $this->errorCallback = null; } public function setBlocksDirPath(string $blocksDirPath): void { $this->blocksDirPath = $blocksDirPath; } public function setBlocksDirNamespace(string $blocksDirNamespace): void { $this->blocksDirNamespace = $blocksDirNamespace; } public function setTwigArgs(array $twigArgs): void { $this->twigArgs = array_merge($this->twigArgs, $twigArgs); } public function setErrorCallback(?callable $errorCallback): void { $this->errorCallback = $errorCallback; } public function setTwigExtension(string $twigExtension): void { $this->twigExtension = $twigExtension; } public function setControllerSuffix(string $controllerSuffix): void { $this->_controllerSuffix = $controllerSuffix; } public function getBlocksDirPath(): string { return $this->blocksDirPath; } public function getBlocksDirNamespace(): string { return $this->blocksDirNamespace; } public function getTwigArgs(): array { return $this->twigArgs; } public function getTwigExtension(): string { return $this->twigExtension; } public function callErrorCallback(array $errors): void { if (! is_callable($this->errorCallback)) { return; } call_user_func_array($this->errorCallback, [$errors,]); } }
Twig
Также вспомогательный класс, лишь уточню что мы расширили twig своей функцией _include (которая является оберткой для встроенного и использует наши поля _isLoaded и _template из метода Controller->getTemplateArgs выше) и фильтр _merge (который отличается тем, что рекурсивно сливает массивы).
Twig.php
<?php declare(strict_types=1); namespace LightSource\FrontBlocksFramework; use Exception; use Twig\Environment; use Twig\Loader\FilesystemLoader; use Twig\Loader\LoaderInterface; use Twig\TwigFilter; use Twig\TwigFunction; class Twig { private ?LoaderInterface $twigLoader; private ?Environment $twigEnvironment; private Settings $settings; public function __construct(Settings $settings, ?LoaderInterface $twigLoader = null) { $this->twigEnvironment = null; $this->settings = $settings; $this->twigLoader = $twigLoader; $this->init(); } // e.g for extend a twig with adding a new filter public function getEnvironment(): ?Environment { return $this->twigEnvironment; } private function extendTwig(): void { $this->twigEnvironment->addFilter( new TwigFilter( '_merge', function ($source, $additional) { return Helper::arrayMergeRecursive($source, $additional); } ) ); $this->twigEnvironment->addFunction( new TwigFunction( '_include', function ($block, $args = []) { $block = Helper::arrayMergeRecursive($block, $args); return $block[Controller::TEMPLATE_KEY__IS_LOADED] ? $this->render($block[Controller::TEMPLATE_KEY__TEMPLATE], $block) : ''; } ) ); } private function init(): void { try { $this->twigLoader = ! $this->twigLoader ? new FilesystemLoader($this->settings->getBlocksDirPath()) : $this->twigLoader; $this->twigEnvironment = new Environment($this->twigLoader, $this->settings->getTwigArgs()); } catch (Exception $ex) { $this->twigEnvironment = null; $this->settings->callErrorCallback( [ 'message' => $ex->getMessage(), 'file' => $ex->getFile(), 'line' => $ex->getLine(), 'trace' => $ex->getTraceAsString(), ] ); return; } $this->extendTwig(); } public function render(string $template, array $args = [], bool $isPrint = false): string { $html = ''; // twig isn't loaded if (is_null($this->twigEnvironment)) { return $html; } try { // will generate ean exception if a template doesn't exist OR broken // also if a var doesn't exist (if using a 'strict_variables' flag, see Twig_Environment->__construct) $html .= $this->twigEnvironment->render($template, $args); } catch (Exception $ex) { $html = ''; $this->settings->callErrorCallback( [ 'message' => $ex->getMessage(), 'file' => $ex->getFile(), 'line' => $ex->getLine(), 'trace' => $ex->getTraceAsString(), 'template' => $template, ] ); } if ($isPrint) { echo $html; } return $html; } }
TwigTest.php
<?php declare(strict_types=1); namespace LightSource\FrontBlocksFramework\Tests\unit; use Codeception\Test\Unit; use Exception; use LightSource\FrontBlocksFramework\Controller; use LightSource\FrontBlocksFramework\Settings; use LightSource\FrontBlocksFramework\Twig; use Twig\Loader\ArrayLoader; class TwigTest extends Unit { private function renderBlock(array $blocks, string $renderBlock, array $renderArgs = []): string { $twigLoader = new ArrayLoader($blocks); $settings = new Settings(); $twig = new Twig($settings, $twigLoader); $content = ''; try { $content = $twig->render($renderBlock, $renderArgs); } catch (Exception $ex) { $this->fail('Twig render exception, ' . $ex->getMessage()); } return $content; } public function testExtendTwigIncludeFunctionWhenBlockIsLoaded() { $blocks = [ 'block-a.twig' => '{{ _include(blockB) }}', 'block-b.twig' => 'block-b content', ]; $renderBlock = 'block-a.twig'; $renderArgs = [ 'blockB' => [ Controller::TEMPLATE_KEY__TEMPLATE => 'block-b.twig', Controller::TEMPLATE_KEY__IS_LOADED => true, ], ]; $this->assertEquals('block-b content', $this->renderBlock($blocks, $renderBlock, $renderArgs)); } public function testExtendTwigIncludeFunctionWhenBlockNotLoaded() { $blocks = [ 'block-a.twig' => '{{ _include(blockB) }}', 'block-b.twig' => 'block-b content', ]; $renderBlock = 'block-a.twig'; $renderArgs = [ 'blockB' => [ Controller::TEMPLATE_KEY__TEMPLATE => 'block-b.twig', Controller::TEMPLATE_KEY__IS_LOADED => false, ], ]; $this->assertEquals('', $this->renderBlock($blocks, $renderBlock, $renderArgs)); } public function testExtendTwigIncludeFunctionWhenArgsPassed() { $blocks = [ 'block-a.twig' => '{{ _include(blockB, {classes:["test-class",],}) }}', 'block-b.twig' => '{{ classes|join(" ") }}', ]; $renderBlock = 'block-a.twig'; $renderArgs = [ 'blockB' => [ Controller::TEMPLATE_KEY__TEMPLATE => 'block-b.twig', Controller::TEMPLATE_KEY__IS_LOADED => true, 'classes' => ['own-class',], ], ]; $this->assertEquals('own-class test-class', $this->renderBlock($blocks, $renderBlock, $renderArgs)); } public function testExtendTwigMergeFilter() { $blocks = [ 'block-a.twig' => '{{ {"array":["a",],}|_merge({"array":["b",],}).array|join(" ") }}', ]; $renderBlock = 'block-a.twig'; $renderArgs = []; $this->assertEquals('a b', $this->renderBlock($blocks, $renderBlock, $renderArgs)); } }
Blocks
Это наш объединяющий класс.
Метод loadAll загружает все контроллеры и вызывает статический метод onLoad у каждого контроллера (в нашем случае это не используется, как было указано выше это необходимо для расширения и выходит за рамки статьи).
Метод renderBlock принимает объект контроллера и производит рендер блока, передавая в twig шаблон аргументы из метода Controller->getTemplateArgs выше. Также добавляет класс используемого контроллера и классы всех его зависимостей в список использованных блоков, что позволит нам далее получить используемый css и js.
Ну и наконец метод getUsedResources используя список выше и статический метод Controller::getResourceInfo позволяет нам после рендера блоков получить используемый css и js код, объединенный в правильной последовательности, т.е. с учетом всех зависимостей./
Blocks.php
<?php declare(strict_types=1); namespace LightSource\FrontBlocksFramework; class Blocks { private array $loadedControllerClasses; private array $usedControllerClasses; private Settings $settings; private Twig $twig; public function __construct(Settings $settings) { $this->loadedControllerClasses = []; $this->usedControllerClasses = []; $this->settings = $settings; $this->twig = new Twig($settings); } final public function getLoadedControllerClasses(): array { return $this->loadedControllerClasses; } final public function getUsedControllerClasses(): array { return $this->usedControllerClasses; } final public function getSettings(): Settings { return $this->settings; } final public function getTwig(): Twig { return $this->twig; } final public function getUsedResources(string $extension, bool $isIncludeSource = false): string { $resourcesContent = ''; foreach ($this->usedControllerClasses as $usedControllerClass) { $getResourcesInfoCallback = [$usedControllerClass, 'getResourceInfo']; if (! is_callable($getResourcesInfoCallback)) { $this->settings->callErrorCallback( [ 'message' => "Controller class doesn't exist", 'class' => $usedControllerClass, ] ); continue; } $resourceInfo = call_user_func_array( $getResourcesInfoCallback, [ $this->settings, ] ); $pathToResourceFile = $this->settings->getBlocksDirPath() . DIRECTORY_SEPARATOR . $resourceInfo['relativeResourcePath'] . $extension; if (! is_file($pathToResourceFile)) { continue; } $resourcesContent .= $isIncludeSource ? "\n/* " . $resourceInfo['resourceName'] . " */\n" : ''; $resourcesContent .= file_get_contents($pathToResourceFile); } return $resourcesContent; } private function loadController(string $phpClass, array $debugArgs): bool { $isLoaded = false; if ( ! class_exists($phpClass, true) || ! is_subclass_of($phpClass, Controller::class) ) { $this->settings->callErrorCallback( [ 'message' => "Class doesn't exist or doesn't child", 'args' => $debugArgs, ] ); return $isLoaded; } call_user_func([$phpClass, 'onLoad']); return true; } private function loadControllers(string $directory, string $namespace, array $controllerFileNames): void { foreach ($controllerFileNames as $controllerFileName) { $phpFile = implode(DIRECTORY_SEPARATOR, [$directory, $controllerFileName]); $phpClass = implode('\\', [$namespace, str_replace('.php', '', $controllerFileName),]); $debugArgs = [ 'directory' => $directory, 'namespace' => $namespace, 'phpFile' => $phpFile, 'phpClass' => $phpClass, ]; if (! $this->loadController($phpClass, $debugArgs)) { continue; } $this->loadedControllerClasses[] = $phpClass; } } private function loadDirectory(string $directory, string $namespace): void { // exclude ., .. $fs = array_diff(scandir($directory), ['.', '..']); $controllerFilePreg = '/' . Settings::$controllerSuffix . '.php$/'; $controllerFileNames = Helper::arrayFilter( $fs, function ($f) use ($controllerFilePreg) { return (1 === preg_match($controllerFilePreg, $f)); }, false ); $subDirectoryNames = Helper::arrayFilter( $fs, function ($f) { return false === strpos($f, '.'); }, false ); foreach ($subDirectoryNames as $subDirectoryName) { $subDirectory = implode(DIRECTORY_SEPARATOR, [$directory, $subDirectoryName]); $subNamespace = implode('\\', [$namespace, $subDirectoryName]); $this->loadDirectory($subDirectory, $subNamespace); } $this->loadControllers($directory, $namespace, $controllerFileNames); } final public function loadAll(): void { $directory = $this->settings->getBlocksDirPath(); $namespace = $this->settings->getBlocksDirNamespace(); $this->loadDirectory($directory, $namespace); } final public function renderBlock(Controller $controller, array $args = [], bool $isPrint = false): string { $dependencies = array_merge($controller->getDependencies(), [get_class($controller),]); $newDependencies = array_diff($dependencies, $this->usedControllerClasses); $this->usedControllerClasses = array_merge($this->usedControllerClasses, $newDependencies); $templateArgs = $controller->getTemplateArgs($this->settings); $templateArgs = Helper::arrayMergeRecursive($templateArgs, $args); return $this->twig->render($templateArgs[Controller::TEMPLATE_KEY__TEMPLATE], $templateArgs, $isPrint); } }
BlocksTest.php
<?php declare(strict_types=1); namespace LightSource\FrontBlocksFramework\Tests\unit; use Codeception\Test\Unit; use Exception; use LightSource\FrontBlocksFramework\Blocks; use LightSource\FrontBlocksFramework\Controller; use LightSource\FrontBlocksFramework\Model; use LightSource\FrontBlocksFramework\Settings; use LightSource\FrontBlocksFramework\Twig; use org\bovigo\vfs\vfsStream; use org\bovigo\vfs\vfsStreamDirectory; class BlocksTest extends Unit { private function getBlocks( string $namespace, vfsStreamDirectory $rootDirectory, array $structure, array $usedControllerClasses = [] ): ?Blocks { vfsStream::create($structure, $rootDirectory); $settings = new Settings(); $settings->setBlocksDirNamespace($namespace); $settings->setBlocksDirPath($rootDirectory->url()); $twig = $this->make( Twig::class, [ 'render' => function (string $template, array $args = [], bool $isPrint = false): string { return ''; }, ] ); try { $blocks = $this->make( Blocks::class, [ 'loadedControllerClasses' => [], 'usedControllerClasses' => $usedControllerClasses, 'twig' => $twig, 'settings' => $settings, ] ); } catch (Exception $ex) { $this->fail("Can't make Blocks stub, " . $ex->getMessage()); } $blocks->loadAll(); return $blocks; } // get a unique namespace depending on a test method to prevent affect other tests private function getUniqueControllerNamespaceWithAutoloader( string $methodConstant, vfsStreamDirectory $rootDirectory ): string { $namespace = str_replace('::', '_', $methodConstant); spl_autoload_register( function ($class) use ($rootDirectory, $namespace) { $targetNamespace = $namespace . '\\'; if (0 !== strpos($class, $targetNamespace)) { return; } $relativePathToFile = str_replace($targetNamespace, '', $class); $relativePathToFile = str_replace('\\', '/', $relativePathToFile); $absPathToFile = $rootDirectory->url() . DIRECTORY_SEPARATOR . $relativePathToFile . '.php'; include_once $absPathToFile; } ); return $namespace; } // get a unique directory name depending on a test method to prevent affect other tests private function getUniqueDirectory(string $methodConstant): vfsStreamDirectory { $dirName = str_replace([':', '\\'], '_', $methodConstant); return vfsStream::setup($dirName); } private function getControllerClassFile(string $namespace, string $class): string { $vendorControllerClass = '\LightSource\FrontBlocksFramework\Controller'; return '<?php namespace ' . $namespace . '; class ' . $class . ' extends ' . $vendorControllerClass . ' {}'; } private function getController(array $dependencies = []) { return new class (null, $dependencies) extends Controller { private array $dependencies; public function __construct(?Model $model = null, array $dependencies) { parent::__construct($model); $this->dependencies = $dependencies; } public function getDependencies(string $sourceClass = ''): array { return $this->dependencies; } public function getTemplateArgs(Settings $settings): array { return [ Controller::TEMPLATE_KEY__TEMPLATE => '', ]; } }; } //// public function testLoadAllControllersWithPrefix() { $rootDirectory = $this->getUniqueDirectory(__METHOD__); $namespace = $this->getUniqueControllerNamespaceWithAutoloader(__METHOD__, $rootDirectory); $blocks = $this->getBlocks( $namespace, $rootDirectory, [ 'Block' => [ 'BlockC.php' => $this->getControllerClassFile("{$namespace}\Block", 'BlockC'), ], ] ); $this->assertEquals( [ "{$namespace}\Block\BlockC", ], $blocks->getLoadedControllerClasses() ); } public function testLoadAllIgnoreControllersWithoutPrefix() { $rootDirectory = $this->getUniqueDirectory(__METHOD__); $namespace = $this->getUniqueControllerNamespaceWithAutoloader(__METHOD__, $rootDirectory); $blocks = $this->getBlocks( $namespace, $rootDirectory, [ 'Block' => [ 'Block.php' => $this->getControllerClassFile("{$namespace}\Block", 'Block'), ], ] ); $this->assertEquals([], $blocks->getLoadedControllerClasses()); } public function testLoadAllIgnoreWrongControllers() { $rootDirectory = $this->getUniqueDirectory(__METHOD__); $namespace = $this->getUniqueControllerNamespaceWithAutoloader(__METHOD__, $rootDirectory); $blocks = $this->getBlocks( $namespace, $rootDirectory, [ 'Block' => [ 'BlockC.php' => $this->getControllerClassFile("{$namespace}\Block", 'WrongBlockC'), ], ] ); $this->assertEquals([], $blocks->getLoadedControllerClasses()); } //// public function testRenderBlockAddsControllerToUsedList() { $rootDirectory = $this->getUniqueDirectory(__METHOD__); $namespace = $this->getUniqueControllerNamespaceWithAutoloader(__METHOD__, $rootDirectory); $blocks = $this->getBlocks($namespace, $rootDirectory, []); $controller = $this->getController(); $blocks->renderBlock($controller); $this->assertEquals( [ get_class($controller), ], $blocks->getUsedControllerClasses() ); } public function testRenderBlockAddsControllerDependenciesToUsedList() { $rootDirectory = $this->getUniqueDirectory(__METHOD__); $namespace = $this->getUniqueControllerNamespaceWithAutoloader(__METHOD__, $rootDirectory); $blocks = $this->getBlocks($namespace, $rootDirectory, []); $controller = $this->getController(['A',]); $blocks->renderBlock($controller); $this->assertEquals( [ 'A', get_class($controller), ], $blocks->getUsedControllerClasses() ); } public function testRenderBlockAddsDependenciesBeforeControllerToUsedList() { $rootDirectory = $this->getUniqueDirectory(__METHOD__); $namespace = $this->getUniqueControllerNamespaceWithAutoloader(__METHOD__, $rootDirectory); $blocks = $this->getBlocks($namespace, $rootDirectory, []); $controller = $this->getController(['A',]); $blocks->renderBlock($controller); $this->assertEquals( [ 'A', get_class($controller), ], $blocks->getUsedControllerClasses() ); } public function testRenderBlockIgnoreDuplicateControllerWhenAddsToUsedList() { $rootDirectory = $this->getUniqueDirectory(__METHOD__); $namespace = $this->getUniqueControllerNamespaceWithAutoloader(__METHOD__, $rootDirectory); $blocks = $this->getBlocks($namespace, $rootDirectory, []); $controllerA = $this->getController(); $blocks->renderBlock($controllerA); $blocks->renderBlock($controllerA); $this->assertEquals( [ get_class($controllerA), ], $blocks->getUsedControllerClasses() ); } public function testRenderBlockIgnoreDuplicateControllerDependenciesWhenAddsToUsedList() { $rootDirectory = $this->getUniqueDirectory(__METHOD__); $namespace = $this->getUniqueControllerNamespaceWithAutoloader(__METHOD__, $rootDirectory); $blocks = $this->getBlocks($namespace, $rootDirectory, []); $controllerA = $this->getController(['A',]); $controllerB = $this->getController(['A',]); $blocks->renderBlock($controllerA); $blocks->renderBlock($controllerB); $this->assertEquals( [ 'A', get_class($controllerA),// $controllerB has the same class ], $blocks->getUsedControllerClasses() ); } //// public function testGetUsedResourcesWhenBlockWithResources() { $rootDirectory = $this->getUniqueDirectory(__METHOD__); $namespace = $this->getUniqueControllerNamespaceWithAutoloader(__METHOD__, $rootDirectory); $blocks = $this->getBlocks( $namespace, $rootDirectory, [ 'Block' => [ 'BlockC.php' => $this->getControllerClassFile("{$namespace}\Block", 'BlockC'), 'block.css' => 'just css code', ], ], [ "{$namespace}\Block\BlockC", ] ); $this->assertEquals( 'just css code', $blocks->getUsedResources('.css', false) ); } public function testGetUsedResourcesWhenBlockWithoutResources() { $rootDirectory = $this->getUniqueDirectory(__METHOD__); $namespace = $this->getUniqueControllerNamespaceWithAutoloader(__METHOD__, $rootDirectory); $blocks = $this->getBlocks( $namespace, $rootDirectory, [ 'Block' => [ 'BlockC.php' => $this->getControllerClassFile("{$namespace}\Block", 'BlockC'), ], ], [ "{$namespace}\Block\BlockC", ] ); $this->assertEquals( '', $blocks->getUsedResources('.css', false) ); } public function testGetUsedResourcesWhenSeveralBlocks() { $rootDirectory = $this->getUniqueDirectory(__METHOD__); $namespace = $this->getUniqueControllerNamespaceWithAutoloader(__METHOD__, $rootDirectory); $blocks = $this->getBlocks( $namespace, $rootDirectory, [ 'BlockA' => [ 'BlockAC.php' => $this->getControllerClassFile("{$namespace}\BlockA", 'BlockAC'), 'block-a.css' => 'css code for a', ], 'BlockB' => [ 'BlockBC.php' => $this->getControllerClassFile("{$namespace}\BlockB", 'BlockBC'), 'block-b.css' => 'css code for b', ], ], [ "{$namespace}\BlockA\BlockAC", "{$namespace}\BlockB\BlockBC", ] ); $this->assertEquals( 'css code for acss code for b', $blocks->getUsedResources('.css', false) ); } public function testGetUsedResourcesWithIncludedSource() { $rootDirectory = $this->getUniqueDirectory(__METHOD__); $namespace = $this->getUniqueControllerNamespaceWithAutoloader(__METHOD__, $rootDirectory); $blocks = $this->getBlocks( $namespace, $rootDirectory, [ 'SimpleBlock' => [ 'SimpleBlockC.php' => $this->getControllerClassFile("{$namespace}\SimpleBlock", 'SimpleBlockC'), 'simple-block.css' => 'css code', ], ], [ "{$namespace}\SimpleBlock\SimpleBlockC", ] ); $this->assertEquals( "\n/* simple-block */\ncss code", $blocks->getUsedResources('.css', true) ); } }
Вот и все, теперь осталось объединить эти части и наш мини-фреймворк готов. Момент публикации composer пакета я опущу, т.к. это довольно простая задача и если вам будет интересно то вы без труда найдете информацию по этому поводу.
Демонстрационный пример
Ниже приведу демонстрационный пример использования пакета, с одним чистым css для наглядности, если кому-то интересен пример с scss/webpack смотрите ссылки в конце статьи.
Создаем блоки для теста, пусть это будут Header, Article и Button. Header и Button будут независимыми блоками, Article будет содержкать Button.
Header
Header.php
<?php namespace LightSource\FrontBlocksExample\Header; use LightSource\FrontBlocksFramework\Model; class Header extends Model { protected string $name; public function loadByTest() { parent::load(); $this->name = 'I\'m Header'; } }
HeaderC.php
<?php namespace LightSource\FrontBlocksExample\Header; use LightSource\FrontBlocksFramework\Controller; class HeaderC extends Controller { public function getModel(): ?Header { /** @noinspection PhpIncompatibleReturnTypeInspection */ return parent::getModel(); } }
header.twig
<div class="header"> {{ name }} </div>
header.css
.header { color: green; border:1px solid green; padding: 10px; }
Article
Article.php
<?php namespace LightSource\FrontBlocksExample\Article; use LightSource\FrontBlocksExample\Button\Button; use LightSource\FrontBlocksFramework\Model; class Article extends Model { protected string $name; protected Button $button; public function loadByTest() { parent::load(); $this->name = 'I\'m Article, I contain another block'; $this->button->loadByTest(); } }
ArticleC.php
<?php namespace LightSource\FrontBlocksExample\Article; use LightSource\FrontBlocksExample\Button\ButtonC; use LightSource\FrontBlocksFramework\Controller; class ArticleC extends Controller { protected ButtonC $button; public function getModel(): ?Article { /** @noinspection PhpIncompatibleReturnTypeInspection */ return parent::getModel(); } }
article.twig
<div class="article"> <p class="article__name">{{ name }}</p> {{ _include(button) }} </div>
article.css
.article { color: orange; border: 1px solid orange; padding: 10px; } .article__name { margin: 0 0 10px; line-height: 1.5; }
Button
Button.php
<?php namespace LightSource\FrontBlocksExample\Button; use LightSource\FrontBlocksFramework\Model; class Button extends Model { protected string $name; public function loadByTest() { parent::load(); $this->name = 'I\'m Button'; } }
ButtonC.php
<?php namespace LightSource\FrontBlocksExample\Button; use LightSource\FrontBlocksFramework\Controller; class ButtonC extends Controller { public function getModel(): ?Button { /** @noinspection PhpIncompatibleReturnTypeInspection */ return parent::getModel(); } }
button.twig
<div class="button"> {{ name }} </div>
button.css
.button { color: black; border: 1px solid black; padding: 10px; }
Подключаем наш пакет и рендерим блоки, результат вставляем в html код страницы, в шапке выводим использованный css код
example.php
<?php use LightSource\FrontBlocksExample\{ Article\ArticleC, Header\HeaderC, }; use LightSource\FrontBlocksFramework\{ Blocks, Settings }; require_once __DIR__ . '/vendors/vendor/autoload.php'; //// settings ini_set('display_errors', 1); $settings = new Settings(); $settings->setBlocksDirNamespace('LightSource\FrontBlocksExample'); $settings->setBlocksDirPath(__DIR__ . '/Blocks'); $settings->setErrorCallback( function (array $errors) { // todo log or any other actions echo '<pre>' . print_r($errors, true) . '</pre>'; } ); $blocks = new Blocks($settings); //// usage $headerController = new HeaderC(); $headerController->getModel()->loadByTest(); $articleController = new ArticleC(); $articleController->getModel()->loadByTest(); $content = $blocks->renderBlock($headerController); $content .= $blocks->renderBlock($articleController); $css = $blocks->getUsedResources('.css', true); //// html ?> <html> <head> <title>Example</title> <style> <?= $css ?> </style> <style> .article { margin-top: 10px; } </style> </head> <body> <?= $content ?> </body> </html>
в результате вывод будет примерно таким
example.png

Послесловие
Данный пакет был создан для личных целей, я использую его в своих проектах и он облегчает мне разработку, и я буду рад если он пригодится кому-то еще. Не стесняйтесь задавать вопросы и комментировать – я буду рад ответить на ваши вопросы и услышать ваше мнение.
Вот и все, спасибо за внимание.
Ссылки:
репозиторий с мини фреймворком
репозиторий с демонстрационным примером
репозиторий с примером использования scss и js в блоках (webpack сборщик)
P.S. Благодарю @alexmixaylov, @bombe и @rpsv за конструктивные комментарии. Часть 2
