Доброго времени суток уважаемые читатели хабра. С каждым годом в веб разработке появляется все больше разнообразных решений которые используют модульный подход и упрощают разработку и редактирование кода. В данной статье я предлагаю вам свой взгляд на то, какими могут быть переиспользуемые 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_Main_C’ будет ‘block—theme—main’
-
-
Реализация
Пришло время перейти к реализации нашей идеи, т.е. к коду.
Ниже части реализации (классы) будут в формате : текстовое описание, код реализации и код тестов. Согласен с людьми, которые говорят что тесты есть лучшая документация, однако к своему стыду я начал использовать их в своих проектах недавно, по-этому не смотря на мои старания их имена или структура могут повергнуть в шок сбивать с толку, просьба не принимать близко к сердцу.
FIELDS_READER
Все наша магия при работе с моделями и контроллерами будет строится на функции ‘get_class_vars’ которая предоставит нам имена полей класса и на ‘ReflectionProperty’ классе, который предоставит нам информацию об этих полях, такую как видимость поля (protected/public) и его тип. Мы будем собирать информацию только о protected полях.
Также упростим дальнейшую разработку, добавив автоинициализацию стандартных полей значением по умолчанию, это избавит нас от необходимости инициализировать их в конструкторе вручную, что при большом количестве блоков сэкономит наше время.
FIELDS_READER.php
<?php declare( strict_types=1 ); namespace LightSource\FrontBlocksFramework; use Exception; use ReflectionProperty; abstract class FIELDS_READER { 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 { // get protected fields without the '__' prefix $fieldNames = array_keys( get_class_vars( static::class ) ); $fieldNames = array_filter( $fieldNames, function ( $fieldName ) { $prefix = substr( $fieldName, 0, 2 ); return '__' !== $prefix; } ); 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; } } }
FIELDS_READERTest.php
<?php declare( strict_types=1 ); namespace LightSource\FrontBlocksFramework\Tests\unit; use Codeception\Test\Unit; use LightSource\FrontBlocksFramework\CONTROLLER; use LightSource\FrontBlocksFramework\FIELDS_READER; use LightSource\FrontBlocksFramework\MODEL; class FIELDS_READERTest extends Unit { public function testReadProtectedField() { $fieldsReader = new class extends FIELDS_READER { protected $_loadedField; public function __construct() { parent::__construct(); } public function getFields() { return $this->_getFieldsInfo(); } }; $this->assertEquals( [ '_loadedField' => '', ], $fieldsReader->getFields() ); } public function testIgnoreReadProtectedPrefixedField() { $fieldsReader = new class extends FIELDS_READER { protected $__unloadedField; public function __construct() { parent::__construct(); } public function getFields() { return $this->_getFieldsInfo(); } }; $this->assertEquals( [], $fieldsReader->getFields() ); } public function testIgnoreReadPublicField() { $fieldsReader = new class extends FIELDS_READER { public $unloadedField; public function __construct() { parent::__construct(); } public function getFields() { return $this->_getFieldsInfo(); } }; $this->assertEquals( [ ], $fieldsReader->getFields() ); } public function testIgnoreReadPrivateField() { $fieldsReader = new class extends FIELDS_READER { private $unloadedField; public function __construct() { parent::__construct(); } public function getFields() { return $this->_getFieldsInfo(); } }; $this->assertEquals( [ ], $fieldsReader->getFields() ); } public function testReadFieldWithType() { $fieldsReader = new class extends FIELDS_READER { 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 FIELDS_READER { 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 FIELDS_READER { 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 FIELDS_READER { 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 FIELDS_READER { 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 FIELDS_READER { 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 FIELDS_READER { 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 FIELDS_READER { 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 FIELDS_READER { 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 FIELDS_READER { protected $_default; public function __construct() { parent::__construct(); } public function getDefault() { return $this->_default; } }; $this->assertTrue( null === $fieldsReader->getDefault() ); } }
MODEL
Данный класс по сути лишь небольшая обертка для класса FIELDS_READER, который содержит поле ‘_isLoaded’, что отвечает за состояние модели, оно пригодится нам когда мы будем работать с twig, и функции ‘getFields’, которая возвращает массив со значениями protected полей, в котором ключи это их имена.
MODEL.php
<?php declare( strict_types=1 ); namespace LightSource\FrontBlocksFramework; abstract class MODEL extends FIELDS_READER { 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() ); } }
CONTROLLER
Данный класс также как и MODEL наследует класс FIELDS_READER, однако имеет и другие важные задачи. Содержит два поля – модель и массив ‘__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 FIELDS_READER { const TEMPLATE_KEY__TEMPLATE = '_template'; const TEMPLATE_KEY__IS_LOADED = '_isLoaded'; private ?MODEL $_model; // using the prefix to prevent load this field protected 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; } 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 ) { $templateFieldName = ltrim( $modelFieldName, '_' ); if ( ! $modelFieldValue instanceof MODEL ) { $templateArgs[ $templateFieldName ] = $modelFieldValue; continue; } $modelFieldController = $this->_getControllerField( $modelFieldName ); $modelFieldArgs = []; $externalFieldArgs = $this->__external[ $modelFieldName ] ?? []; if ( $modelFieldController ) { $modelFieldController->setModel( $modelFieldValue ); $modelFieldArgs = $modelFieldController->getTemplateArgs( $settings ); } $templateArgs[ $templateFieldName ] = 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 ) { $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\\Block_C' ) ); } 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\\BlockName_C' ) ); } 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_Main_C' ) ); } 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_JustMain_C' ) ); } //// 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->__external['_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 из метода CONROLLER->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 шаблон аргументы из метода CONROLLER->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; } function getDependencies( string $sourceClass = '' ): array { return $this->_dependencies; } function getTemplateArgs( Settings $settings ): array { return [ CONTROLLER::TEMPLATE_KEY__TEMPLATE => '', ]; } }; } //// public function testLoadAllControllersWithPrefix() { // fixme $rootDirectory = $this->_getUniqueDirectory( __METHOD__ ); $namespace = $this->_getUniqueControllerNamespaceWithAutoloader( __METHOD__, $rootDirectory ); $blocks = $this->_getBlocks( $namespace, $rootDirectory, [ 'Block' => [ 'Block_C.php' => $this->_getControllerClassFile( "{$namespace}\Block", 'Block_C' ), ], ] ); $this->assertEquals( [ "{$namespace}\Block\Block_C", ], $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' => [ 'Block_C.php' => $this->_getControllerClassFile( "{$namespace}\Block", 'WrongBlock_C' ), ], ] ); $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' => [ 'Block_C.php' => $this->_getControllerClassFile( "{$namespace}\Block", 'Block_C' ), 'block.css' => 'just css code', ], ], [ "{$namespace}\Block\Block_C", ] ); $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' => [ 'Block_C.php' => $this->_getControllerClassFile( "{$namespace}\Block", 'Block_C' ), ], ], [ "{$namespace}\Block\Block_C", ] ); $this->assertEquals( '', $blocks->getUsedResources( '.css', false ) ); } public function testGetUsedResourcesWhenSeveralBlocks() { $rootDirectory = $this->_getUniqueDirectory( __METHOD__ ); $namespace = $this->_getUniqueControllerNamespaceWithAutoloader( __METHOD__, $rootDirectory ); $blocks = $this->_getBlocks( $namespace, $rootDirectory, [ 'BlockA' => [ 'BlockA_C.php' => $this->_getControllerClassFile( "{$namespace}\BlockA", 'BlockA_C' ), 'block-a.css' => 'css code for a', ], 'BlockB' => [ 'BlockB_C.php' => $this->_getControllerClassFile( "{$namespace}\BlockB", 'BlockB_C' ), 'block-b.css' => 'css code for b', ], ], [ "{$namespace}\BlockA\BlockA_C", "{$namespace}\BlockB\BlockB_C", ] ); $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' => [ 'SimpleBlock_C.php' => $this->_getControllerClassFile( "{$namespace}\SimpleBlock", 'SimpleBlock_C' ), 'simple-block.css' => 'css code', ], ], [ "{$namespace}\SimpleBlock\SimpleBlock_C", ] ); $this->assertEquals( "\n/* simple-block */\ncss code", $blocks->getUsedResources( '.css', true ) ); } }
Вот и все, теперь осталось объединить эти части и наш мини-фреймворк готов. Момент публикации composer пакета я опущу, т.к. это довольно простая задача и если вам будет интересно то вы без труда найдете информацию по этому поводу.
Демонстрационный пример
Ниже приведу демонстрационный пример использования пакета, с одним чистым css для наглядности, если кому-то интересен пример с scss/webpack смотрите ссылки в конце статьи.
Создаем блоки для теста, BlockA и BlockC будут независимыми блоками, BlockB будет содержкать BlockC.
BlockA
BlockA.php
<?php namespace LightSource\FrontBlocksExample\BlockA; use LightSource\FrontBlocksFramework\MODEL; class BlockA extends MODEL { protected string $_name; public function load() { parent::_load(); $this->_name = 'I\'m BlockA'; } }
BlockA_C.php
/sp
<?php namespace LightSource\FrontBlocksExample\BlockA; use LightSource\FrontBlocksFramework\Blocks; use LightSource\FrontBlocksFramework\CONTROLLER; class BlockA_C extends CONTROLLER { public function getModel(): ?BlockA { /** @noinspection PhpIncompatibleReturnTypeInspection */ return parent::getModel(); } }
block-a.twig
/
<div class="block-a"> {{ name }} </div>
block-a.css
Bl
.block-a { color: green; border:1px solid green; padding: 10px; }
BlockB
BlockB.php
<?php namespace LightSource\FrontBlocksExample\BlockB; use LightSource\FrontBlocksExample\BlockC\BlockC; use LightSource\FrontBlocksFramework\MODEL; class BlockB extends MODEL { protected string $_name; protected BlockC $_blockC; public function __construct() { parent::__construct(); $this->_blockC = new BlockC(); } public function load() { parent::_load(); $this->_name = 'I\'m BlockB, I contain another block'; $this->_blockC->load(); } }
BlockB_C.php
<?php namespace LightSource\FrontBlocksExample\BlockB; use LightSource\FrontBlocksExample\BlockC\BlockC_C; use LightSource\FrontBlocksFramework\CONTROLLER; class BlockB_C extends CONTROLLER { protected BlockC_C $_blockC; public function getModel(): ?BlockB { /** @noinspection PhpIncompatibleReturnTypeInspection */ return parent::getModel(); } }
block-b.twig
<div class="block-b"> <p class="block-b__name">{{ name }}</p> {{ _include(blockC) }} </div>
block-b.css
Blo
.block-b { color: orange; border: 1px solid orange; padding: 10px; } .block-b__name { margin: 0 0 10px; line-height: 1.5; }
BlocksC
BlockC.php
<?php namespace LightSource\FrontBlocksExample\BlockC; use LightSource\FrontBlocksFramework\MODEL; class BlockC extends MODEL { protected string $_name; public function load() { parent::_load(); $this->_name = 'I\'m BlockC'; } }
BlockC_C.php
/
<?php namespace LightSource\FrontBlocksExample\BlockC; use LightSource\FrontBlocksFramework\CONTROLLER; class BlockC_C extends CONTROLLER { public function getModel(): ?BlockC { /** @noinspection PhpIncompatibleReturnTypeInspection */ return parent::getModel(); } }
Подключаем наш пакет и рендерим блоки
block-c.twig
<div class="block-c"> {{ name }} </div>
block-c.css
.block-c { color: black; border: 1px solid black; padding: 10px; }
Подключаем наш пакет и рендерим блоки, результат вставляем в html код страницы, в шапке выводим использованный css код
example.php
<?php use LightSource\FrontBlocksExample\{ BlockA\BlockA_C, BlockB\BlockB_C, }; use LightSource\FrontBlocksFramework\{ Blocks, Settings }; require_once __DIR__ . '/vendors/vendor/autoload.php'; //// settings $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 $blockA_Controller = new BlockA_C(); $blockA_Controller->getModel()->load(); $blockB_Controller = new BlockB_C(); $blockB_Controller->getModel()->load(); $content = $blocks->renderBlock( $blockA_Controller ); $content .= $blocks->renderBlock( $blockB_Controller ); $css = $blocks->getUsedResources( '.css', true ); //// html ?> <html> <head> <title>Example</title> <style> <?= $css ?> </style> <style> .block-b { margin-top: 10px; } </style> </head> <body> <?= $content ?> </body> </html>
в результате вывод будет примерно таким
example.png

Послесловие
Данный пакет был создан для личных целей, я использую его в своих проектах и он облегчает мне разработку, и я буду рад если он пригодится кому-то еще. Не стесняйтесь задавать вопросы и комментировать – я буду рад ответить на ваши вопросы и услышать ваше мнение.
Вот и все, спасибо за внимание.
Ссылки:
репозиторий с мини фреймворком
репозиторий с демонстрационным примером
репозиторий с примером использования scss и js в блоках (webpack сборщик)
репозиторий с примером использования в WordPress теме (здесь вы также можете увидеть пример расширения класса контроллера и использования автозагрузки всех контоллеров, что добавляет поддержку ajax запросов для блоков).
ссылка на оригинал статьи https://habr.com/ru/post/556494/
Добавить комментарий