Русский
Русский
English
Статистика
Реклама

Bem

Модульные front-end блоки пишем свой мини фреймворк

09.05.2021 16:23:04 | Автор: admin

Доброго времени суток уважаемые читатели хабра. С каждым годом в веб разработке появляется все больше разнообразных решений которые используют модульный подход и упрощают разработку и редактирование кода. В данной статье я предлагаю вам свой взгляд на то, какими могут быть переиспользуемые 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, отмечу важный для меня пункт, то что он дисциплинирует, т.е. заставляет отделять обработку от вывода и подготавливать переменные заранее, и в данном случае мы будем использовать его.

Теперь давайте продумаем структуру нашего мини фреймворка более детально.

  1. Блок

    Каждый блок будет состоять из:

    1. Статических ресурсов (css/js/twig)

    2. Класса модели (его поля мы будет предоставлять как данные для twig шаблона)

    3. Класса контролера (он будет отвечать за наши ресурсы, их зависимости друг от друга и связывать модель с twig шаблоном)

  2. Вспомогательные классы : Класс Settings (будет содержать путь к блокам, их пространство имен и т.д.), класс обертка для Twig пакета

  3. Blocks класс

    Связующий класс, который :

    1. будет содержать вспомогательные классы (Settings, Twig)

    2. предоставлять функцию рендера блока

    3. содержать список использованных блоков, чтобы иметь возможность получить их ресурсы (css/js)

    4. автоматически загружать все контроллеры (небольшой задел на будущее, это немного выходит за рамки текущей статьи, скажу просто что необходимо для тестов и возможности расширения)

Требования к блокам

Теперь когда мы определились со структурой пришло время зажечь оправдать слово фреймворк в названии для нашего пакета, а именно указать требования к коду наших блоков:

  • php 7.4+

  • Все блоки должны иметь одну родительскую директорию

  • Классы моделей и контроллеров должны иметь PSR-4 совместимое пространство имен с автозагрузчиком (PSR-4 де факто стандарт, если вы используете автозагрузчик от composer, т.е. указываете autoload/psr4 директиву в вашем composer.json то ваш проект уже соответствует этому требованию)

  • Соглашение об именах:

    • Имя контроллера должно содержать _C суффикс

    • Класс модели должен иметь то же пространство имен и то же имя (без суффикса) что и соответствующих контроллер

    • Имена ресурсов должны соответствовать имени контроллера, но с данными отличиями:

      • Без суффикса контроллера

      • Верблюжья нотация в имени должны быть заменена на тире (CamelCase = camel-case)

      • Нижнее подчеркивание в имени должно быть заменено на тире (just_block = just-block)

      • Таким образом по правилам выше имя ресурса с контроллером Block_Theme_Main_C будет blocktheme--main

Реализация

Пришло время перейти к реализации нашей идеи, т.е. к коду.

Ниже части реализации (классы) будут в формате : текстовое описание, код реализации и код тестов. Согласен с людьми, которые говорят что тесты есть лучшая документация, однако к своему стыду я начал использовать их в своих проектах недавно, по-этому не смотря на мои старания их имена или структура могут повергнуть в шок сбивать с толку, просьба не принимать близко к сердцу.

FIELDS_READER

Все наша магия при работе с моделями и контроллерами будет строится на функции get_class_vars которая предоставит нам имена полей класса и на ReflectionProperty классе, который предоставит нам информацию об этих полях, такую как видимость поля (protected/public) и его тип. Мы будем собирать информацию только о protected полях.

Также упростим дальнейшую разработку, добавив автоинициализацию стандартных полей значением по умолчанию, это избавит нас от необходимости инициализировать их в конструкторе вручную, что при большом количестве блоков сэкономит наше время.

FIELDS_READER.php
<?phpdeclare( 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 fieldsif ( is_null( $fieldType ) ) {continue;}$this->_fieldsInfo[ $fieldName ] = $fieldType;}}private function _autoInitFields(): void {foreach ( $this->_fieldsInfo as $fieldName => $fieldType ) {// ignore fields without a typeif ( ! $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
<?phpdeclare( 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
<?phpdeclare( 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
<?phpdeclare( 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
<?phpdeclare( 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 fieldprotected 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 supportreturn 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 typeif ( ( $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 supportpublic 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
<?phpdeclare( 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
<?phpdeclare( 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
<?phpdeclare( 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 filterpublic 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 loadedif ( 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
<?phpdeclare( 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
<?phpdeclare( 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
<?phpdeclare( 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 testsprivate 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 testsprivate 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
<?phpnamespace 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

<?phpnamespace 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
<?phpnamespace 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
<?phpnamespace 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
<?phpnamespace 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

/

<?phpnamespace 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
<?phpuse 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 actionsecho '<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 запросов для блоков).

Подробнее..

Модульные frond-end блоки пишем свой пакет. Часть 2

20.05.2021 14:22:33 | Автор: admin

В первой части я поделился своим взглядом на то, какими могут быть переиспользуемые front-end блоки, получил конструктивную критику, доработал пакет и теперь хотел бы поделиться с вами новой версией. Она позволит легко организовать использование модульных блоков для любого проекта с бекендом на php.

Для тех кто не знаком с первой частью я буду оставлять спойлеры из нее, которые введут в курс дела. Тем кому интересен конечный результат - демонстрационный пример и ссылки на репозитории в конце статьи.

Предисловие

Представлюсь - я молодой веб разработчик с опытом работы 5 лет. Крайний год я работаю на фрилансе и большая часть текущих проектов связана с WordPress. Несмотря на различую критику CMS в общем и WordPress в часности, я считаю сама архитектура WordPress это довольно удачное решение, хотя конечно не без определенных недостатков. И один из них на мой взгляд это шаблоны. В крайних обновлениях сделаны большие шаги чтобы это исправить, и Gutenberg в целом становится мощным инструментом, однако к сожалению в большинстве тем продолжается каша в шаблонах, стилях и скриптах, которая делает редактирование чего-либо крайне болезненным, а переиспользование кода зачастую невозможным. Именно эта проблема и подтолкнуло меня к идее своего пакета, который бы организовывал структуру и позволял переиспользовать блоки.

Реализация будет в виде composer пакета, который можно будет использовать в совершенно различных проектах, без привязки к WordPress.

Постановка задачи

Понятие блок ниже будет по сути тем же понятием что и блок в BEM методологии, т.е. это будет группа html/js/css кода которая будет представлять одну сущность.

Генерировать html и управлять зависимостями блоков мы будем через php, что говорит о том, что наш пакет будет подходить для проектов с бекендом на php. Также условимся на берегу что, не вдаваясь в споры, не будем поддаваться влиянию новомодных вещей, таких как css-in-js или bem-json и будем придерживаться эль-классико классического подхода, т.е. предполагать что html, css и js это разные файлы.

Теперь давайте сформулируем наши основные требования к пакету:

  • Обеспечить структуру блоков

  • Предоставить поддержку наследования (расширения) блоков

  • Предоставить возможность использовать блок в блоке и соответственно поддержку зависимости ресурсов одного блока от ресурсов других блоков

Структура пакета

О ресурах блока и twig шаблонах

Как условились выше, такие ресурсы как css и js всегда будут в виде обычных файлов, т.е. это будут .js и .css или .min.css и .min.js в случае использования препроцесссоров и сборщиков (как webpack например). Для вставки данных в html код мы будем использовать шаблонизатор Twig (для тех кто не знаком ссылка). Кто-то может заметить, что php и сам по себе хороший шаблонизатор, не будем вдаваться в споры, кроме доводов указанных на главной странице проекта Twig, отмечу важный для меня пункт, то что он дисциплинирует, т.е. заставляет отделять обработку от вывода и подготавливать переменные заранее, и в данном случае мы будем использовать его.

  1. Блок

    Каждый блок будет состоять из:

    1. Статических ресурсов (css/js/twig)

    2. Класса блока, который будет предоставлять данные для twig шаблона и управлять зависимостями.

  2. Вспомогательные классы: Settings (пути к блокам и их пространства имен), TwigWrapper (обертка для Twig пакета), BlocksLoader (автозагрузка всех блоков, опционально), Helper (набор статических доп. функций)

  3. Renderer класс - связующий класс, который будет объединять вспомогательные классы, предоставлять функцию рендера блока, содержать список использованных блоков и их ресурсы (css, js)

Требования к блокам

В отличии от первого пакета количество требований сократилось, теперь это:

  • php 7.4

  • Классы блоков должны иметь PSR-4 совместимое пространство имен с автозагрузчиком (PSR-4 де факто стандарт, если вы используете автозагрузчик от composer, т.е. указываете autoload/psr4 директиву в вашем composer.json то ваш проект уже соответствует этому требованию)

  • Имена ресурсов должны совпадать с именем блока (например для Button.php будут Button.css и Button.twig)

Реализация

Ниже части реализации (классы) будут в формате : текстовое описание, код реализации и код тестов.

Block

Основной действующий класс, его потомки будут содержать данные для twig шаблона (в protected полях) и предоставлять список зависимостей, а также мы сможем получить путь к ресурсам (шаблону, стилям). Все наша магия при работе с полями будет строится на функции get_class_vars которая предоставит имена полей класса и на ReflectionProperty классе, который предоставит информацию об этих полях, такую как видимость поля (protected/public) и его тип. Мы будем собирать информацию только о protected полях.

Также упростим дальнейшую разработку, добавив автоинициализацию полей значением по умолчанию, это избавит нас от необходимости инициализировать их в конструкторе вручную, что при большом количестве блоков сэкономит наше время.

Block.php
<?phpdeclare(strict_types=1);namespace LightSource\FrontBlocks;use Exception;use ReflectionProperty;abstract class Block{    public const TEMPLATE_KEY_NAMESPACE = '_namespace';    public const TEMPLATE_KEY_TEMPLATE = '_template';    public const TEMPLATE_KEY_IS_LOADED = '_isLoaded';    public const RESOURCE_KEY_NAMESPACE = 'namespace';    public const RESOURCE_KEY_FOLDER = 'folder';    public const RESOURCE_KEY_RELATIVE_RESOURCE_PATH = 'relativeResourcePath';    public const RESOURCE_KEY_RELATIVE_BLOCK_PATH = 'relativeBlockPath';    public const RESOURCE_KEY_RESOURCE_NAME = 'resourceName';    private array $fieldsInfo;    private bool $isLoaded;    public function __construct()    {        $this->fieldsInfo = [];        $this->isLoaded   = false;        $this->readFieldsInfo();        $this->autoInitFields();    }    public static function onLoad()    {    }    public static function getResourceInfo(Settings $settings, string $blockClass = ''): ?array    {        // using static for child support        $blockClass = ! $blockClass ?            static::class :            $blockClass;        // e.g. $blockClass = Namespace/Example/Theme/Main/ExampleThemeMain        $resourceInfo = [            self::RESOURCE_KEY_NAMESPACE              => '',            self::RESOURCE_KEY_FOLDER                 => '',            self::RESOURCE_KEY_RELATIVE_RESOURCE_PATH => '',// e.g. Example/Theme/Main/ExampleThemeMain            self::RESOURCE_KEY_RELATIVE_BLOCK_PATH    => '',// e.g. Example/Theme/Main            self::RESOURCE_KEY_RESOURCE_NAME          => '',// e.g. ExampleThemeMain        ];        $blockFolderInfo = $settings->getBlockFolderInfoByBlockClass($blockClass);        if (! $blockFolderInfo) {            $settings->callErrorCallback(                [                    'error'      => 'Block has the non registered namespace',                    'blockClass' => $blockClass,                ]            );            return null;        }        $resourceInfo[self::RESOURCE_KEY_NAMESPACE] = $blockFolderInfo['namespace'];        $resourceInfo[self::RESOURCE_KEY_FOLDER]    = $blockFolderInfo['folder'];        //  e.g. Example/Theme/Main/ExampleThemeMain        $relativeBlockNamespace = str_replace($resourceInfo[self::RESOURCE_KEY_NAMESPACE] . '\\', '', $blockClass);        // e.g. ExampleThemeMain        $blockName = explode('\\', $relativeBlockNamespace);        $blockName = $blockName[count($blockName) - 1];        // e.g. Example/Theme/Main        $relativePath = explode('\\', $relativeBlockNamespace);        $relativePath = array_slice($relativePath, 0, count($relativePath) - 1);        $relativePath = implode(DIRECTORY_SEPARATOR, $relativePath);        $resourceInfo[self::RESOURCE_KEY_RELATIVE_RESOURCE_PATH] = $relativePath . DIRECTORY_SEPARATOR . $blockName;        $resourceInfo[self::RESOURCE_KEY_RELATIVE_BLOCK_PATH]    = $relativePath;        $resourceInfo[self::RESOURCE_KEY_RESOURCE_NAME]          = $blockName;        return $resourceInfo;    }    private static function getResourceInfoForTwigTemplate(Settings $settings, string $blockClass): ?array    {        $resourceInfo = self::getResourceInfo($settings, $blockClass);        if (! $resourceInfo) {            return null;        }        $absTwigPath = implode(            '',            [                $resourceInfo['folder'],                DIRECTORY_SEPARATOR,                $resourceInfo['relativeResourcePath'],                $settings->getTwigExtension(),            ]        );        if (! is_file($absTwigPath)) {            $parentClass = get_parent_class($blockClass);            if ($parentClass &&                is_subclass_of($parentClass, self::class) &&                self::class !== $parentClass) {                return self::getResourceInfoForTwigTemplate($settings, $parentClass);            } else {                return null;            }        }        return $resourceInfo;    }    final public function getFieldsInfo(): array    {        return $this->fieldsInfo;    }    final public function isLoaded(): bool    {        return $this->isLoaded;    }    private function getBlockField(string $fieldName): ?Block    {        $block      = null;        $fieldsInfo = $this->fieldsInfo;        if (key_exists($fieldName, $fieldsInfo)) {            $block = $this->{$fieldName};            // prevent possible recursion by a mistake (if someone will create a field with self)            // using static for children support            $block = ($block &&                      $block instanceof Block &&                      get_class($block) !== static::class) ?                $block :                null;        }        return $block;    }    public function getDependencies(string $sourceClass = ''): array    {        $dependencyClasses = [];        $fieldsInfo        = $this->fieldsInfo;        foreach ($fieldsInfo as $fieldName => $fieldType) {            $dependencyBlock = $this->getBlockField($fieldName);            if (! $dependencyBlock) {                continue;            }            $dependencyClass = get_class($dependencyBlock);            // 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 = $dependencyBlock->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 add external arguments    public function getTemplateArgs(Settings $settings): array    {        // using static for child support        $resourceInfo = self::getResourceInfoForTwigTemplate($settings, static::class);        $pathToTemplate = $resourceInfo ?            $resourceInfo[self::RESOURCE_KEY_RELATIVE_RESOURCE_PATH] . $settings->getTwigExtension() :            '';        $namespace      = $resourceInfo[self::RESOURCE_KEY_NAMESPACE] ?? '';        $templateArgs = [            self::TEMPLATE_KEY_NAMESPACE => $namespace,            self::TEMPLATE_KEY_TEMPLATE  => $pathToTemplate,            self::TEMPLATE_KEY_IS_LOADED => $this->isLoaded,        ];        if (! $pathToTemplate) {            $settings->callErrorCallback(                [                    'error' => 'Twig template is missing for the block',                    // using static for child support                    'class' => static::class,                ]            );        }        foreach ($this->fieldsInfo as $fieldName => $fieldType) {            $value = $this->{$fieldName};            if ($value instanceof self) {                $value = $value->getTemplateArgs($settings);            }            $templateArgs[$fieldName] = $value;        }        return $templateArgs;    }    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, Block::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;        }    }    final protected function load(): void    {        $this->isLoaded = true;    }}
BlockTest.php
<?phpdeclare(strict_types=1);namespace LightSource\FrontBlocks\Tests\unit;use Codeception\Test\Unit;use LightSource\FrontBlocks\Block;use LightSource\FrontBlocks\Settings;use org\bovigo\vfs\vfsStream;use UnitTester;class BlockTest extends Unit{    protected UnitTester $tester;    public function testReadProtectedFields()    {        $block = new class extends Block {            protected $loadedField;        };        $this->assertEquals(            ['loadedField',],            array_keys($block->getFieldsInfo())        );    }    public function testIgnoreReadPublicFields()    {        $block = new class extends Block {            public $ignoredField;        };        $this->assertEquals(            [],            array_keys($block->getFieldsInfo())        );    }    public function testReadFieldWithType()    {        $block = new class extends Block {            protected string $loadedField;        };        $this->assertEquals(            [                'loadedField' => 'string',            ],            $block->getFieldsInfo()        );    }    public function testReadFieldWithoutType()    {        $block = new class extends Block {            protected $loadedField;        };        $this->assertEquals(            [                'loadedField' => '',            ],            $block->getFieldsInfo()        );    }    public function testAutoInitIntField()    {        $block = new class extends Block {            protected int $int;            public function getInt()            {                return $this->int;            }        };        $this->assertTrue(0 === $block->getInt());    }    public function testAutoInitFloatField()    {        $block = new class extends Block {            protected float $float;            public function getFloat()            {                return $this->float;            }        };        $this->assertTrue(0.0 === $block->getFloat());    }    public function testAutoInitStringField()    {        $block = new class extends Block {            protected string $string;            public function getString()            {                return $this->string;            }        };        $this->assertTrue('' === $block->getString());    }    public function testAutoInitBoolField()    {        $block = new class extends Block {            protected bool $bool;            public function getBool()            {                return $this->bool;            }        };        $this->assertTrue(false === $block->getBool());    }    public function testAutoInitArrayField()    {        $block = new class extends Block {            protected array $array;            public function getArray()            {                return $this->array;            }        };        $this->assertTrue([] === $block->getArray());    }    public function testAutoInitBlockField()    {        $testBlock        = new class extends Block {        };        $testBlockClass   = get_class($testBlock);        $block            = new class ($testBlockClass) extends Block {            protected $block;            private $testClass;            public function __construct($testClass)            {                $this->testClass = $testClass;                parent::__construct();            }            public function getFieldType(string $fieldName): ?string            {                return ('block' === $fieldName ?                    $this->testClass :                    parent::getFieldType($fieldName));            }            public function getBlock()            {                return $this->block;            }        };        $actualBlockClass = $block->getBlock() ?            get_class($block->getBlock()) :            '';        $this->assertEquals($actualBlockClass, $testBlockClass);    }    public function testIgnoreAutoInitFieldWithoutType()    {        $block = new class extends Block {            protected $default;            public function getDefault()            {                return $this->default;            }        };        $this->assertTrue(null === $block->getDefault());    }    public function testGetResourceInfo()    {        $settings = new Settings();        $settings->addBlocksFolder('TestNamespace', 'test-folder');        $this->assertEquals(            [                Block::RESOURCE_KEY_NAMESPACE              => 'TestNamespace',                Block::RESOURCE_KEY_FOLDER                 => 'test-folder',                Block::RESOURCE_KEY_RELATIVE_RESOURCE_PATH => 'Button/Theme/Red/ButtonThemeRed',                Block::RESOURCE_KEY_RELATIVE_BLOCK_PATH    => 'Button/Theme/Red',                Block::RESOURCE_KEY_RESOURCE_NAME          => 'ButtonThemeRed',            ],            Block::getResourceInfo($settings, 'TestNamespace\\Button\\Theme\\Red\\ButtonThemeRed')        );    }    public function testGetDependenciesWithSubDependenciesRecursively()    {        $spanBlock   = new class extends Block {        };        $buttonBlock = new class ($spanBlock) extends Block {            protected $spanBlock;            public function __construct($spanBlock)            {                parent::__construct();                $this->spanBlock = $spanBlock;            }        };        $formBlock   = new class ($buttonBlock) extends Block {            protected $buttonBlock;            public function __construct($buttonBlock)            {                parent::__construct();                $this->buttonBlock = $buttonBlock;            }        };        $this->assertEquals(            [                get_class($spanBlock),                get_class($buttonBlock),            ],            $formBlock->getDependencies()        );    }    public function testGetDependenciesInRightOrder()    {        $spanBlock   = new class extends Block {        };        $buttonBlock = new class ($spanBlock) extends Block {            protected $spanBlock;            public function __construct($spanBlock)            {                parent::__construct();                $this->spanBlock = $spanBlock;            }        };        $formBlock   = new class ($buttonBlock) extends Block {            protected $buttonBlock;            public function __construct($buttonBlock)            {                parent::__construct();                $this->buttonBlock = $buttonBlock;            }        };        $this->assertEquals(            [                get_class($spanBlock),                get_class($buttonBlock),            ],            $formBlock->getDependencies()        );    }    public function testGetDependenciesWhenBlocksAreDependentFromEachOther()    {        $buttonBlock = new class extends Block {            protected $formBlock;            public function __construct()            {                parent::__construct();            }            public function setFormBlock($formBlock)            {                $this->formBlock = $formBlock;            }        };        $formBlock   = new class ($buttonBlock) extends Block {            protected $buttonBlock;            public function __construct($buttonBlock)            {                parent::__construct();                $this->buttonBlock = $buttonBlock;            }        };        $buttonBlock->setFormBlock($formBlock);        $this->assertEquals(            [                get_class($buttonBlock),            ],            $formBlock->getDependencies()        );    }    public function testGetDependenciesWithoutDuplicatesWhenSeveralWithOneType()    {        function getButtonBlock()        {            return new class extends Block {            };        }        $inputBlock = new class (getButtonBlock()) extends Block {            protected $buttonBlock;            public function __construct($buttonBlock)            {                parent::__construct();                $this->buttonBlock = $buttonBlock;            }        };        $formBlock = new class ($inputBlock) extends Block {            protected $inputBlock;            protected $firstButtonBlock;            protected $secondButtonBlock;            public function __construct($inputBlock)            {                parent::__construct();                $this->inputBlock        = $inputBlock;                $this->firstButtonBlock  = getButtonBlock();                $this->secondButtonBlock = getButtonBlock();            }        };        $this->assertEquals(            [                get_class(getButtonBlock()),                get_class($inputBlock),            ],            $formBlock->getDependencies()        );    }    public function testGetTemplateArgsWhenBlockContainsBuiltInTypes()    {        $settings    = new Settings();        $buttonBlock = new class extends Block {            protected string $name;            public function __construct()            {                parent::__construct();                $this->name = 'button';            }        };        $this->assertEquals(            [                Block::TEMPLATE_KEY_NAMESPACE => '',                Block::TEMPLATE_KEY_TEMPLATE  => '',                Block::TEMPLATE_KEY_IS_LOADED => false,                'name'                        => 'button',            ],            $buttonBlock->getTemplateArgs($settings)        );    }    public function testGetTemplateArgsWhenBlockContainsAnotherBlockRecursively()    {        $settings    = new Settings();        $spanBlock   = new class extends Block {            protected string $name;            public function __construct()            {                parent::__construct();                $this->name = 'span';            }        };        $buttonBlock = new class ($spanBlock) extends Block {            protected $spanBlock;            public function __construct($spanBlock)            {                parent::__construct();                $this->spanBlock = $spanBlock;            }        };        $formBlock   = new class ($buttonBlock) extends Block {            protected $buttonBlock;            public function __construct($buttonBlock)            {                parent::__construct();                $this->buttonBlock = $buttonBlock;            }        };        $this->assertEquals(            [                Block::TEMPLATE_KEY_NAMESPACE => '',                Block::TEMPLATE_KEY_TEMPLATE  => '',                Block::TEMPLATE_KEY_IS_LOADED => false,                'buttonBlock'                 => [                    Block::TEMPLATE_KEY_NAMESPACE => '',                    Block::TEMPLATE_KEY_TEMPLATE  => '',                    Block::TEMPLATE_KEY_IS_LOADED => false,                    'spanBlock'                   => [                        Block::TEMPLATE_KEY_NAMESPACE => '',                        Block::TEMPLATE_KEY_TEMPLATE  => '',                        Block::TEMPLATE_KEY_IS_LOADED => false,                        'name'                        => 'span',                    ],                ],            ],            $formBlock->getTemplateArgs($settings)        );    }    public function testGetTemplateArgsWhenTemplateIsInParent()    {        $rootDirectory = $this->tester->getUniqueDirectory(__METHOD__);        $namespace     = $this->tester->getUniqueControllerNamespaceWithAutoloader(__METHOD__, $rootDirectory->url());        $blocksFolder  = vfsStream::create(            [                'ButtonBase'  => [                    'ButtonBase.php'  => $this->tester->getBlockClassFile(                        $namespace . '\ButtonBase',                        'ButtonBase',                        '\\' . Block::class                    ),                    'ButtonBase.twig' => '',                ],                'ButtonChild' => [                    'ButtonChild.php' => $this->tester->getBlockClassFile(                        $namespace . '\ButtonChild',                        'ButtonChild',                        '\\' . $namespace . '\ButtonBase\ButtonBase'                    ),                ],            ],            $rootDirectory        );        $settings = new Settings();        $settings->addBlocksFolder($namespace, $blocksFolder->url());        $buttonChildClass = $namespace . '\ButtonChild\ButtonChild';        $buttonChild      = new $buttonChildClass();        if (! $buttonChild instanceof Block) {            $this->fail("Class doesn't child to Block");        }        $this->assertEquals(            [                Block::TEMPLATE_KEY_NAMESPACE => $namespace,                Block::TEMPLATE_KEY_TEMPLATE  => 'ButtonBase/ButtonBase.twig',                Block::TEMPLATE_KEY_IS_LOADED => false,            ],            $buttonChild->getTemplateArgs($settings)        );    }}

BlocksLoader

Предоставляет опциональную возможность автозагрузки всех блоков, при этом у каждого блока будет вызван статический метод ::onLoad, что может быть использовано для расширения, например поддержки ajax запросов и т.д.

BlocksLoader.php
<?phpdeclare(strict_types=1);namespace LightSource\FrontBlocks;class BlocksLoader{    private array $loadedBlockClasses;    private Settings $settings;    public function __construct(Settings $settings)    {        $this->loadedBlockClasses = [];        $this->settings           = $settings;    }    final public function getLoadedBlockClasses(): array    {        return $this->loadedBlockClasses;    }    private function tryToLoadBlock(string $phpClass): bool    {        $isLoaded = false;        if (            ! class_exists($phpClass, true) ||            ! is_subclass_of($phpClass, Block::class)        ) {            // without any error, because php files can contain other things            return $isLoaded;        }        call_user_func([$phpClass, 'onLoad']);        return true;    }    private function loadBlocks(string $namespace, array $phpFileNames): void    {        foreach ($phpFileNames as $phpFileName) {            $phpClass = implode('\\', [$namespace, str_replace('.php', '', $phpFileName),]);            if (! $this->tryToLoadBlock($phpClass)) {                continue;            }            $this->loadedBlockClasses[] = $phpClass;        }    }    private function loadDirectory(string $directory, string $namespace): void    {        // exclude ., ..        $fs = array_diff(scandir($directory), ['.', '..']);        $phpFilePreg = '/.php$/';        $phpFileNames      = Helper::arrayFilter(            $fs,            function ($f) use ($phpFilePreg) {                return (1 === preg_match($phpFilePreg, $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->loadBlocks($namespace, $phpFileNames);    }    final public function loadAllBlocks(): void    {        $blockFoldersInfo = $this->settings->getBlockFoldersInfo();        foreach ($blockFoldersInfo as $namespace => $folder) {            $this->loadDirectory($folder, $namespace);        }    }}
BlocksLoaderTest.php
<?phpdeclare(strict_types=1);namespace LightSource\FrontBlocks\Tests\unit;use Codeception\Test\Unit;use LightSource\FrontBlocks\Block;use LightSource\FrontBlocks\BlocksLoader;use LightSource\FrontBlocks\Settings;use org\bovigo\vfs\vfsStream;use UnitTester;class BlocksLoaderTest extends Unit{    protected UnitTester $tester;    public function testLoadAllBlocksWhichChildToBlock()    {        $rootDirectory = $this->tester->getUniqueDirectory(__METHOD__);        $namespace     = $this->tester->getUniqueControllerNamespaceWithAutoloader(__METHOD__, $rootDirectory->url());        $blocksFolder  = vfsStream::create(            [                'ButtonBase'  => [                    'ButtonBase.php' => $this->tester->getBlockClassFile(                        $namespace . '\ButtonBase',                        'ButtonBase',                        '\\' . Block::class                    ),                ],                'ButtonChild' => [                    'ButtonChild.php' => $this->tester->getBlockClassFile(                        $namespace . '\ButtonChild',                        'ButtonChild',                        '\\' . $namespace . '\ButtonBase\ButtonBase'                    ),                ],            ],            $rootDirectory        );        $settings = new Settings();        $settings->addBlocksFolder($namespace, $blocksFolder->url());        $blocksLoader = new BlocksLoader($settings);        $blocksLoader->loadAllBlocks();        $this->assertEquals(            [                $namespace . '\ButtonBase\ButtonBase',                $namespace . '\ButtonChild\ButtonChild',            ],            $blocksLoader->getLoadedBlockClasses()        );    }    public function testLoadAllBlocksIgnoreNonChild()    {        $rootDirectory = $this->tester->getUniqueDirectory(__METHOD__);        $namespace     = $this->tester->getUniqueControllerNamespaceWithAutoloader(__METHOD__, $rootDirectory->url());        $blocksFolder  = vfsStream::create(            [                'ButtonBase' => [                    'ButtonBase.php' => '<?php use ' . $namespace . '; class ButtonBase{}',                ],            ],            $rootDirectory        );        $settings = new Settings();        $settings->addBlocksFolder($namespace, $blocksFolder->url());        $blocksLoader = new BlocksLoader($settings);        $blocksLoader->loadAllBlocks();        $this->assertEmpty($blocksLoader->getLoadedBlockClasses());    }    public function testLoadAllBlocksInSeveralFolders()    {        $rootDirectory   = $this->tester->getUniqueDirectory(__METHOD__);        $firstFolderUrl  = $rootDirectory->url() . '/First';        $secondFolderUrl = $rootDirectory->url() . '/Second';        $firstNamespace  = $this->tester->getUniqueControllerNamespaceWithAutoloader(            __METHOD__ . '_first',            $firstFolderUrl,        );        $secondNamespace = $this->tester->getUniqueControllerNamespaceWithAutoloader(            __METHOD__ . '_second',            $secondFolderUrl,        );        vfsStream::create(            [                'First'  => [                    'ButtonBase' => [                        'ButtonBase.php' => $this->tester->getBlockClassFile(                            $firstNamespace . '\ButtonBase',                            'ButtonBase',                            '\\' . Block::class                        ),                    ],                ],                'Second' => [                    'ButtonBase' => [                        'ButtonBase.php' => $this->tester->getBlockClassFile(                            $secondNamespace . '\ButtonBase',                            'ButtonBase',                            '\\' . Block::class                        ),                    ],                ],            ],            $rootDirectory        );        $settings = new Settings();        $settings->addBlocksFolder($firstNamespace, $firstFolderUrl);        $settings->addBlocksFolder($secondNamespace, $secondFolderUrl);        $blocksLoader = new BlocksLoader($settings);        $blocksLoader->loadAllBlocks();        $this->assertEquals(            [                $firstNamespace . '\ButtonBase\ButtonBase',                $secondNamespace . '\ButtonBase\ButtonBase',            ],            $blocksLoader->getLoadedBlockClasses()        );    }}

Renderer

Связующий класс, объединяет вспомогательные классы, предоставляет функцию рендера блока, содержит список использованных блоков и их ресурсы (css, js)

Renderer.php
<?phpdeclare(strict_types=1);namespace LightSource\FrontBlocks;class Renderer{    private Settings $settings;    private TwigWrapper $twigWrapper;    private BlocksLoader $blocksLoader;    private array $usedBlockClasses;    public function __construct(Settings $settings)    {        $this->settings         = $settings;        $this->twigWrapper             = new TwigWrapper($settings);        $this->blocksLoader     = new BlocksLoader($settings);        $this->usedBlockClasses = [];    }    final public function getSettings(): Settings    {        return $this->settings;    }    final public function getTwigWrapper(): TwigWrapper    {        return $this->twigWrapper;    }    final public function getBlocksLoader(): BlocksLoader    {        return $this->blocksLoader;    }    final public function getUsedBlockClasses(): array    {        return $this->usedBlockClasses;    }    final public function getUsedResources(string $extension, bool $isIncludeSource = false): string    {        $resourcesContent = '';        foreach ($this->usedBlockClasses as $usedBlockClass) {            $getResourcesInfoCallback = [$usedBlockClass, 'getResourceInfo'];            if (! is_callable($getResourcesInfoCallback)) {                $this->settings->callErrorCallback(                    [                        'message' => "Block class doesn't exist",                        'class'   => $usedBlockClass,                    ]                );                continue;            }            $resourceInfo = call_user_func_array(                $getResourcesInfoCallback,                [                    $this->settings,                ]            );            $pathToResourceFile = $resourceInfo['folder'] .                                  DIRECTORY_SEPARATOR . $resourceInfo['relativeResourcePath'] . $extension;            if (! is_file($pathToResourceFile)) {                continue;            }            $resourcesContent .= $isIncludeSource ?                "\n/* " . $resourceInfo['resourceName'] . " */\n" :                '';            $resourcesContent .= file_get_contents($pathToResourceFile);        }        return $resourcesContent;    }    final public function render(Block $block, array $args = [], bool $isPrint = false): string    {        $dependencies           = array_merge($block->getDependencies(), [get_class($block),]);        $newDependencies        = array_diff($dependencies, $this->usedBlockClasses);        $this->usedBlockClasses = array_merge($this->usedBlockClasses, $newDependencies);        $templateArgs           = $block->getTemplateArgs($this->settings);        $templateArgs           = Helper::arrayMergeRecursive($templateArgs, $args);        $namespace              = $templateArgs[Block::TEMPLATE_KEY_NAMESPACE];        $relativePathToTemplate = $templateArgs[Block::TEMPLATE_KEY_TEMPLATE];        // log already exists        if (! $relativePathToTemplate) {            return '';        }        return $this->twigWrapper->render($namespace, $relativePathToTemplate, $templateArgs, $isPrint);    }}
RendererTest.php
<?phpdeclare(strict_types=1);namespace LightSource\FrontBlocks\Tests\unit;use Codeception\Test\Unit;use LightSource\FrontBlocks\Block;use LightSource\FrontBlocks\Renderer;use LightSource\FrontBlocks\Settings;use org\bovigo\vfs\vfsStream;use UnitTester;class RendererTest extends Unit{    protected UnitTester $tester;    public function testRenderAddsBlockToUsedList()    {        $settings = new Settings();        $renderer = new Renderer($settings);        $button = new class extends Block {        };        $renderer->render($button);        $this->assertEquals(            [                get_class($button),            ],            $renderer->getUsedBlockClasses()        );    }    public function testRenderAddsBlockDependenciesToUsedList()    {        $settings = new Settings();        $renderer = new Renderer($settings);        $button = new class extends Block {        };        $form   = new class ($button) extends Block {            protected $button;            public function __construct($button)            {                parent::__construct();                $this->button = $button;            }        };        $renderer->render($form);        $this->assertEquals(            [                get_class($button),                get_class($form),            ],            $renderer->getUsedBlockClasses()        );    }    public function testRenderAddsDependenciesBeforeBlockToUsedList()    {        $settings = new Settings();        $renderer = new Renderer($settings);        $button = new class extends Block {        };        $form   = new class ($button) extends Block {            protected $button;            public function __construct($button)            {                parent::__construct();                $this->button = $button;            }        };        $renderer->render($form);        $this->assertEquals(            [                get_class($button),                get_class($form),            ],            $renderer->getUsedBlockClasses()        );    }    public function testRenderAddsBlockToUsedListOnce()    {        $settings = new Settings();        $renderer = new Renderer($settings);        $button = new class extends Block {        };        $renderer->render($button);        $renderer->render($button);        $this->assertEquals(            [                get_class($button),            ],            $renderer->getUsedBlockClasses()        );    }    public function testRenderAddsBlockDependenciesToUsedListOnce()    {        $settings = new Settings();        $renderer = new Renderer($settings);        $button = new class extends Block {        };        $form   = new class ($button) extends Block {            protected $button;            public function __construct($button)            {                parent::__construct();                $this->button = $button;            }        };        $footer = new class ($button) extends Block {            protected $button;            public function __construct($button)            {                parent::__construct();                $this->button = $button;            }        };        $renderer->render($form);        $renderer->render($footer);        $this->assertEquals(            [                get_class($button),                get_class($form),                get_class($footer),            ],            $renderer->getUsedBlockClasses()        );    }    public function testGetUsedResources()    {        $rootDirectory = $this->tester->getUniqueDirectory(__METHOD__);        $namespace     = $this->tester->getUniqueControllerNamespaceWithAutoloader(__METHOD__, $rootDirectory->url());        $blocksFolder  = vfsStream::create(            [                'Button' => [                    'Button.php' => $this->tester->getBlockClassFile(                        $namespace . '\Button',                        'Button',                        '\\' . Block::class                    ),                    'Button.css' => '.button{}',                ],                'Form'   => [                    'Form.php' => $this->tester->getBlockClassFile(                        $namespace . '\Form',                        'Form',                        '\\' . Block::class                    ),                    'Form.css' => '.form{}',                ],            ],            $rootDirectory        );        $formClass   = $namespace . '\Form\Form';        $form        = new $formClass();        $buttonClass = $namespace . '\Button\Button';        $button      = new $buttonClass();        $settings = new Settings();        $settings->addBlocksFolder($namespace, $blocksFolder->url());        $renderer = new Renderer($settings);        $renderer->render($button);        $renderer->render($form);        $this->assertEquals('.button{}.form{}', $renderer->getUsedResources('.css'));    }    public function testGetUsedResourcesWithIncludedSource()    {        $rootDirectory = $this->tester->getUniqueDirectory(__METHOD__);        $namespace     = $this->tester->getUniqueControllerNamespaceWithAutoloader(__METHOD__, $rootDirectory->url());        $blocksFolder  = vfsStream::create(            [                'Button' => [                    'Button.php' => $this->tester->getBlockClassFile(                        $namespace . '\Button',                        'Button',                        '\\' . Block::class                    ),                    'Button.css' => '.button{}',                ],                'Form'   => [                    'Form.php' => $this->tester->getBlockClassFile(                        $namespace . '\Form',                        'Form',                        '\\' . Block::class                    ),                    'Form.css' => '.form{}',                ],            ],            $rootDirectory        );        $formClass   = $namespace . '\Form\Form';        $form        = new $formClass();        $buttonClass = $namespace . '\Button\Button';        $button      = new $buttonClass();        $settings = new Settings();        $settings->addBlocksFolder($namespace, $blocksFolder->url());        $renderer = new Renderer($settings);        $renderer->render($button);        $renderer->render($form);        $this->assertEquals(            "\n/* Button */\n.button{}\n/* Form */\n.form{}",            $renderer->getUsedResources('.css', true)        );    }}

Settings

Вспомогательный класс, основные данные это пути к блокам и их пространства имен

Settings.php
<?phpdeclare(strict_types=1);namespace LightSource\FrontBlocks;class Settings{    private array $blockFoldersInfo;    private array $twigArgs;    private string $twigExtension;    private $errorCallback;    public function __construct()    {        $this->blockFoldersInfo = [];        $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 addBlocksFolder(string $namespace, string $folder): void    {        $this->blockFoldersInfo[$namespace] = $folder;    }    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 getBlockFoldersInfo(): array    {        return $this->blockFoldersInfo;    }    public function getBlockFolderInfoByBlockClass(string $blockClass): ?array    {        foreach ($this->blockFoldersInfo as $blockNamespace => $blockFolder) {            if (0 !== strpos($blockClass, $blockNamespace)) {                continue;            }            return [                'namespace' => $blockNamespace,                'folder'    => $blockFolder,            ];        }        return null;    }    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,]);    }}
SettingsTest.php
<?phpdeclare(strict_types=1);namespace LightSource\FrontBlocks\Tests\unit;use Codeception\Test\Unit;use LightSource\FrontBlocks\Settings;class SettingsTest extends Unit{    public function testGetBlockFolderInfoByBlockClass()    {        $settings = new Settings();        $settings->addBlocksFolder('TestNamespace', 'test-folder');        $this->assertEquals(            [                'namespace' => 'TestNamespace',                'folder'    => 'test-folder',            ],            $settings->getBlockFolderInfoByBlockClass('TestNamespace\Class')        );    }    public function testGetBlockFolderInfoByBlockClassWhenSeveral()    {        $settings = new Settings();        $settings->addBlocksFolder('FirstNamespace', 'first-namespace');        $settings->addBlocksFolder('SecondNamespace', 'second-namespace');        $this->assertEquals(            [                'namespace' => 'FirstNamespace',                'folder'    => 'first-namespace',            ],            $settings->getBlockFolderInfoByBlockClass('FirstNamespace\Class')        );    }    public function testGetBlockFolderInfoByBlockClassIgnoreWrong()    {        $settings = new Settings();        $settings->addBlocksFolder('TestNamespace', 'test-folder');        $this->assertEquals(            null,            $settings->getBlockFolderInfoByBlockClass('WrongNamespace\Class')        );    }}

TwigWrapper

Класс обертка для Twig пакета, обеспечиват работу с шаблонами. Также расширили twig своей функцией _include (которая является оберткой для встроенного include и использует наши поля _isLoaded и _template из метода Block->getTemplateArgs выше) и фильтром _merge (который отличается тем, что рекурсивно сливает массивы).

TwigWrapper.php
<?phpdeclare(strict_types=1);namespace LightSource\FrontBlocks;use Exception;use Twig\Environment;use Twig\Loader\FilesystemLoader;use Twig\Loader\LoaderInterface;use Twig\TwigFilter;use Twig\TwigFunction;class TwigWrapper{    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();    }    private static function GetTwigNamespace(string $namespace)    {        return str_replace('\\', '_', $namespace);    }    // 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[Block::TEMPLATE_KEY_IS_LOADED] ?                        $this->render(                            $block[Block::TEMPLATE_KEY_NAMESPACE],                            $block[Block::TEMPLATE_KEY_TEMPLATE],                            $block                        ) :                        '';                }            )        );    }    private function init(): void    {        $blockFoldersInfo = $this->settings->getBlockFoldersInfo();        try {            // can be already init (in tests)            if (! $this->twigLoader) {                $this->twigLoader = new FilesystemLoader();                foreach ($blockFoldersInfo as $namespace => $folder) {                    $this->twigLoader->addPath($folder, self::GetTwigNamespace($namespace));                }            }            $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 $namespace, string $template, array $args = [], bool $isPrint = false): string    {        $html = '';        // twig isn't loaded        if (is_null($this->twigEnvironment)) {            return $html;        }        // can be empty, e.g. for tests        $twigNamespace = $namespace ?            '@' . self::GetTwigNamespace($namespace) . '/' :            '';        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($twigNamespace . $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;    }}
TwigWrapperTest.php
<?phpdeclare(strict_types=1);namespace LightSource\FrontBlocks\Tests\unit;use Codeception\Test\Unit;use LightSource\FrontBlocks\Block;use LightSource\FrontBlocks\Settings;use LightSource\FrontBlocks\TwigWrapper;use Twig\Loader\ArrayLoader;class TwigWrapperTest extends Unit{    private function renderBlock(array $blocks, string $template, array $renderArgs = []): string    {        $twigLoader = new ArrayLoader($blocks);        $settings   = new Settings();        $twig       = new TwigWrapper($settings, $twigLoader);        return $twig->render('', $template, $renderArgs);    }    public function testExtendTwigIncludeFunctionWhenBlockIsLoaded()    {        $blocks     = [            'form.twig'   => '{{ _include(button) }}',            'button.twig' => 'button content',        ];        $template   = 'form.twig';        $renderArgs = [            'button' => [                Block::TEMPLATE_KEY_NAMESPACE => '',                Block::TEMPLATE_KEY_TEMPLATE  => 'button.twig',                Block::TEMPLATE_KEY_IS_LOADED => true,            ],        ];        $this->assertEquals('button content', $this->renderBlock($blocks, $template, $renderArgs));    }    public function testExtendTwigIncludeFunctionWhenBlockNotLoaded()    {        $blocks     = [            'form.twig'   => '{{ _include(button) }}',            'button.twig' => 'button content',        ];        $template   = 'form.twig';        $renderArgs = [            'button' => [                Block::TEMPLATE_KEY_NAMESPACE => '',                Block::TEMPLATE_KEY_TEMPLATE  => 'button.twig',                Block::TEMPLATE_KEY_IS_LOADED => false,            ],        ];        $this->assertEquals('', $this->renderBlock($blocks, $template, $renderArgs));    }    public function testExtendTwigIncludeFunctionWhenArgsPassed()    {        $blocks     = [            'form.twig'   => '{{ _include(button,{classes:["test-class",],}) }}',            'button.twig' => '{{ classes|join(" ") }}',        ];        $template   = 'form.twig';        $renderArgs = [            'button' => [                Block::TEMPLATE_KEY_NAMESPACE => '',                Block::TEMPLATE_KEY_TEMPLATE  => 'button.twig',                Block::TEMPLATE_KEY_IS_LOADED => true,                'classes'                     => ['own-class',],            ],        ];        $this->assertEquals('own-class test-class', $this->renderBlock($blocks, $template, $renderArgs));    }    public function testExtendTwigMergeFilter()    {        $blocks     = [            'button.twig' => '{{ {"array":["first",],}|_merge({"array":["second",],}).array|join(" ") }}',        ];        $template   = 'button.twig';        $renderArgs = [];        $this->assertEquals('first second', $this->renderBlock($blocks, $template, $renderArgs));    }}

Helper

Вспомогательный класс, содержит лишь пару функций, оставлю без комментариев.

Helper.php
<?phpdeclare(strict_types=1);namespace LightSource\FrontBlocks;abstract class Helper{    final public static function arrayFilter(array $array, callable $callback, bool $isSaveKeys): array    {        $arrayResult = array_filter($array, $callback);        return $isSaveKeys ?            $arrayResult :            array_values($arrayResult);    }    final public static function arrayMergeRecursive(array $args1, array $args2): array    {        foreach ($args2 as $key => $value) {            if (intval($key) === $key) {                $args1[] = $value;                continue;            }            // recursive sub-merge for internal arrays            if (                is_array($value) &&                key_exists($key, $args1) &&                is_array($args1[$key])            ) {                $value = self::arrayMergeRecursive($args1[$key], $value);            }            $args1[$key] = $value;        }        return $args1;    }}
HelperTest.php
<?phpdeclare(strict_types=1);namespace LightSource\FrontBlocks\Tests\unit;use Codeception\Test\Unit;use LightSource\FrontBlocks\Helper;class HelperTest extends Unit{    public function testArrayFilterWithoutSaveKeys()    {        $this->assertEquals(            [                0 => '2',            ],            Helper::ArrayFilter(                ['1', '2'],                function ($value) {                    return '1' !== $value;                },                false            )        );    }    public function testArrayFilterWithSaveKeys()    {        $this->assertEquals(            [                1 => '2',            ],            Helper::ArrayFilter(                ['1', '2'],                function ($value) {                    return '1' !== $value;                },                true            )        );    }    public function testArrayMergeRecursive()    {        $this->assertEquals(            [                'classes' => [                    'first',                    'second',                ],                'value'   => 2,            ],            Helper::arrayMergeRecursive(                [                    'classes' => [                        'first',                    ],                    'value'   => 1,                ],                [                    'classes' => [                        'second',                    ],                    'value'   => 2,                ]            )        );    }}

Это был последний класс, теперь можно переходить к демонстрационному примеру.

Демонстрационный пример

Ниже приведу демонстрационный пример использования пакета, с одним чистым css для наглядности, если кому-то интересен пример с scss/webpack смотрите ссылки в конце статьи.

Создаем блоки для теста, пусть это будут Header, Article и Button. Header и Button будут независимыми блоками, Article будет содержкать Button.

Header

Header.php
<?phpnamespace LightSource\FrontBlocksSample\Header;use LightSource\FrontBlocks\Block;class Header extends Block{    protected string $name;    public function loadByTest()    {        parent::load();        $this->name = 'I\'m Header';    }}
Header.twig
<div class="header">    {{ name }}</div>
Header.css
.header {    color: green;    border:1px solid green;    padding: 10px;}

Button

Button.php
<?phpnamespace LightSource\FrontBlocksSample\Button;use LightSource\FrontBlocks\Block;class Button extends Block{    protected string $name;    public function loadByTest()    {        parent::load();        $this->name = 'I\'m Button';    }}
Button.twig
<div class="button">    {{ name }}</div>
Button.css
.button {    color: black;    border: 1px solid black;    padding: 10px;}

Article

Article.php
<?phpnamespace LightSource\FrontBlocksSample\Article;use LightSource\FrontBlocks\Block;use LightSource\FrontBlocksSample\Button\Button;class Article extends Block{    protected string $name;    protected Button $button;    public function loadByTest()    {        parent::load();        $this->name = 'I\'m Article, I contain another block';        $this->button->loadByTest();    }}
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;}

Далее подключаем пакет и рендерим блоки, результат вставляем в html код страницы, в шапке выводим использованный css код

example.php
<?phpuse LightSource\FrontBlocks\{    Renderer,    Settings};use LightSource\FrontBlocksSample\{    Article\Article,    Header\Header};require_once __DIR__ . '/vendors/vendor/autoload.php';//// settingsini_set('display_errors', 1);$settings = new Settings();$settings->addBlocksFolder('LightSource\FrontBlocksSample', __DIR__ . '/Blocks');$settings->setErrorCallback(    function (array $errors) {        // todo log or any other actions        echo '<pre>' . print_r($errors, true) . '</pre>';    });$renderer = new Renderer($settings);//// usage$header = new Header();$header->loadByTest();$article = new Article();$article->loadByTest();$content = $renderer->render($header);$content .= $renderer->render($article);$css     = $renderer->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 сборщик)

репозиторий с примером использования в WordPress теме (здесь вы также можете увидеть пример расширения класса блока и использования автозагрузки, что добавляет поддержку ajax запросов для блоков)

P.S. Благодарю @alexmixaylov, @bombe и @rpsv за конструктивные комментарии к первой части.

Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru