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

от автора

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

Реализация

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

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

FIELDS_READER

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

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

FIELDS_READER.php
<?php  declare( strict_types=1 );  namespace LightSource\FrontBlocksFramework;  use Exception; use ReflectionProperty;  abstract class FIELDS_READER {  	private array $_fieldsInfo;  	public function __construct() {  		$this->_fieldsInfo = [];  		$this->_readFieldsInfo(); 		$this->_autoInitFields();  	}  	final protected function _getFieldsInfo(): array { 		return $this->_fieldsInfo; 	}  	protected function _getFieldType( string $fieldName ): ?string {  		$fieldType = null;  		try { 			// used static for child support 			$property = new ReflectionProperty( static::class, $fieldName ); 		} catch ( Exception $ex ) { 			return $fieldType; 		}  		if ( ! $property->isProtected() ) { 			return $fieldType; 		}  		return $property->getType() ? 			$property->getType()->getName() : 			''; 	}  	private function _readFieldsInfo(): void {  		// get protected fields without the '__' prefix  		$fieldNames = array_keys( get_class_vars( static::class ) ); 		$fieldNames = array_filter( $fieldNames, function ( $fieldName ) {  			$prefix = substr( $fieldName, 0, 2 );  			return '__' !== $prefix; 		} );  		foreach ( $fieldNames as $fieldName ) {  			$fieldType = $this->_getFieldType( $fieldName );  			// only protected fields 			if ( is_null( $fieldType ) ) { 				continue; 			}  			$this->_fieldsInfo[ $fieldName ] = $fieldType;  		}  	}  	private function _autoInitFields(): void {  		foreach ( $this->_fieldsInfo as $fieldName => $fieldType ) {  			// ignore fields without a type 			if ( ! $fieldType ) { 				continue; 			}  			$defaultValue = null;  			switch ( $fieldType ) { 				case 'int': 				case 'float': 					$defaultValue = 0; 					break; 				case 'bool': 					$defaultValue = false; 					break; 				case 'string': 					$defaultValue = ''; 					break; 				case 'array': 					$defaultValue = []; 					break; 			}  			try {  				if ( is_subclass_of( $fieldType, MODEL::class ) || 				     is_subclass_of( $fieldType, CONTROLLER::class ) ) { 					$defaultValue = new $fieldType(); 				}  			} catch ( Exception $ex ) { 				$defaultValue = null; 			}  			// ignore fields with a custom type (null by default) 			if ( is_null( $defaultValue ) ) { 				continue; 			}  			$this->{$fieldName} = $defaultValue;  		}  	}  } 
FIELDS_READERTest.php
<?php  declare( strict_types=1 );  namespace LightSource\FrontBlocksFramework\Tests\unit;  use Codeception\Test\Unit; use LightSource\FrontBlocksFramework\CONTROLLER; use LightSource\FrontBlocksFramework\FIELDS_READER; use LightSource\FrontBlocksFramework\MODEL;  class FIELDS_READERTest extends Unit {  	public function testReadProtectedField() {  		$fieldsReader = new class extends FIELDS_READER {  			protected $_loadedField;  			public function __construct() {  				parent::__construct();  			}  			public function getFields() { 				return $this->_getFieldsInfo(); 			}  		};  		$this->assertEquals( [ 			'_loadedField' => '', 		], $fieldsReader->getFields() );  	}  	public function testIgnoreReadProtectedPrefixedField() {  		$fieldsReader = new class extends FIELDS_READER {  			protected $__unloadedField;  			public function __construct() {  				parent::__construct();  			}  			public function getFields() { 				return $this->_getFieldsInfo(); 			}  		};  		$this->assertEquals( [], $fieldsReader->getFields() );  	}  	public function testIgnoreReadPublicField() {  		$fieldsReader = new class extends FIELDS_READER {  			public $unloadedField;  			public function __construct() {  				parent::__construct();  			}  			public function getFields() { 				return $this->_getFieldsInfo(); 			}  		};  		$this->assertEquals( [ 		], $fieldsReader->getFields() );  	}  	public function testIgnoreReadPrivateField() {  		$fieldsReader = new class extends FIELDS_READER {  			private $unloadedField;  			public function __construct() {  				parent::__construct();  			}  			public function getFields() { 				return $this->_getFieldsInfo(); 			}  		};  		$this->assertEquals( [ 		], $fieldsReader->getFields() );  	}  	public function testReadFieldWithType() {  		$fieldsReader = new class extends FIELDS_READER {  			protected string $_loadedField;  			public function __construct() {  				parent::__construct();  			}  			public function getFields() { 				return $this->_getFieldsInfo(); 			}  		};  		$this->assertEquals( [ 			'_loadedField' => 'string', 		], $fieldsReader->getFields() );  	}  	public function testReadFieldWithoutType() {  		$fieldsReader = new class extends FIELDS_READER {  			protected $_loadedField;  			public function __construct() {  				parent::__construct();  			}  			public function getFields() { 				return $this->_getFieldsInfo(); 			}  		};  		$this->assertEquals( [ 			'_loadedField' => '', 		], $fieldsReader->getFields() );  	}  	////  	public function testAutoInitIntField() {  		$fieldsReader = new class extends FIELDS_READER {  			protected int $_int;  			public function __construct() {  				parent::__construct();  			}  			public function getInt() { 				return $this->_int; 			}  		};  		$this->assertTrue( 0 === $fieldsReader->getInt() );  	}  	public function testAutoInitFloatField() {  		$fieldsReader = new class extends FIELDS_READER {  			protected float $_float;  			public function __construct() {  				parent::__construct();  			}  			public function getFloat() { 				return $this->_float; 			}  		};  		$this->assertTrue( 0.0 === $fieldsReader->getFloat() );  	}  	public function testAutoInitStringField() {  		$fieldsReader = new class extends FIELDS_READER {  			protected string $_string;  			public function __construct() {  				parent::__construct();  			}  			public function getString() { 				return $this->_string; 			}  		};  		$this->assertTrue( '' === $fieldsReader->getString() );  	}  	public function testAutoInitBoolField() {  		$fieldsReader = new class extends FIELDS_READER {  			protected bool $_bool;  			public function __construct() {  				parent::__construct();  			}  			public function getBool() { 				return $this->_bool; 			}  		};  		$this->assertTrue( false === $fieldsReader->getBool() );  	}  	public function testAutoInitArrayField() {  		$fieldsReader = new class extends FIELDS_READER {  			protected array $_array;  			public function __construct() {  				parent::__construct();  			}  			public function getArray() { 				return $this->_array; 			}  		};  		$this->assertTrue( [] === $fieldsReader->getArray() );  	}  	public function testAutoInitModelField() {  		$testModel        = new class extends MODEL { 		}; 		$testModelClass   = get_class( $testModel ); 		$fieldsReader     = new class ( $testModelClass ) extends FIELDS_READER {  			protected $_model; 			private $_testClass;  			public function __construct( $testClass ) {  				$this->_testClass = $testClass; 				parent::__construct();  			}  			public function _getFieldType( string $fieldName ): ?string { 				return ( '_model' === $fieldName ? 					$this->_testClass : 					parent::_getFieldType( $fieldName ) ); 			}  			public function getModel() { 				return $this->_model; 			}  		}; 		$actualModelClass = $fieldsReader->getModel() ? 			get_class( $fieldsReader->getModel() ) : 			'';  		$this->assertEquals( $actualModelClass, $testModelClass );  	}  	public function testAutoInitControllerField() {  		$testController      = new class extends CONTROLLER { 		}; 		$testControllerClass = get_class( $testController ); 		$fieldsReader        = new class ( $testControllerClass ) extends FIELDS_READER {  			protected $_controller; 			private $_testClass;  			public function __construct( $testControllerClass ) {  				$this->_testClass = $testControllerClass; 				parent::__construct();  			}  			public function _getFieldType( string $fieldName ): ?string { 				return ( '_controller' === $fieldName ? 					$this->_testClass : 					parent::_getFieldType( $fieldName ) ); 			}  			public function getController() { 				return $this->_controller; 			}  		}; 		$actualModelClass    = $fieldsReader->getController() ? 			get_class( $fieldsReader->getController() ) : 			'';  		$this->assertEquals( $actualModelClass, $testControllerClass );  	}  	public function testIgnoreInitFieldWithoutType() {  		$fieldsReader = new class extends FIELDS_READER {  			protected $_default;  			public function __construct() {  				parent::__construct();  			}  			public function getDefault() { 				return $this->_default; 			}  		};  		$this->assertTrue( null === $fieldsReader->getDefault() );  	}  } 

MODEL

Данный класс по сути лишь небольшая обертка для класса FIELDS_READER, который содержит поле ‘_isLoaded’, что отвечает за состояние модели, оно пригодится нам когда мы будем работать с twig, и функции ‘getFields’, которая возвращает массив со значениями protected полей, в котором ключи это их имена.

MODEL.php
<?php  declare( strict_types=1 );  namespace LightSource\FrontBlocksFramework;  abstract class MODEL extends FIELDS_READER {  	private bool $_isLoaded;  	public function __construct() {  		parent::__construct();  		$this->_isLoaded = false;  	}  	final public function isLoaded(): bool { 		return $this->_isLoaded; 	}  	public function getFields(): array {  		$args = [];  		$fieldsInfo = $this->_getFieldsInfo();  		foreach ( $fieldsInfo as $fieldName => $fieldType ) { 			$args[ $fieldName ] = $this->{$fieldName}; 		}  		return $args; 	}  	final protected function _load(): void { 		$this->_isLoaded = true; 	}  } 
MODELTest.php
<?php  declare( strict_types=1 );  namespace LightSource\FrontBlocksFramework\Tests\unit;  use Codeception\Test\Unit; use LightSource\FrontBlocksFramework\MODEL;  class MODELTest extends Unit {  	public function testGetFields() {  		$model = new class extends MODEL {  			protected string $_field1;  			public function __construct() {  				parent::__construct();  			}  			public function update() { 				$this->_field1 = 'just string'; 			}  		};  		$model->update();  		$this->assertEquals( [ 			'_field1'   => 'just string', 		], $model->getFields() );  	}  } 

CONTROLLER

Данный класс также как и MODEL наследует класс FIELDS_READER, однако имеет и другие важные задачи. Содержит два поля – модель и массив ‘__external’, который пригодится нам далее при работе с twig шаблоном.

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

Метод getTemplateArgs будет возвращать данные для twig шаблона, это все protected поля соответствующей модели (без префикса ‘_’ если есть) и два дополнительных поля, _template и _isLoaded, первое будет содержать путь к шаблону, а второе отображать состояние модели. Также в этом методе мы реализуем возможность использовать блок в блоке (т.е. иметь класс Model в другом классе Model как поле) — мы соединяем поля контроллера и поля соответствующей модели по имени : т.е. если каждому полю с типом контроллер мы находим соответствующее поле в модели (с типом модель), то мы инициализируем поле контроллер моделью и вызываем метод getTemplateArgs у этого контроллера, получая таким образом все необходимую информацию для отображения этого вложенного блока.

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

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

CONTROLLER.php
<?php  declare( strict_types=1 );  namespace LightSource\FrontBlocksFramework;  use Exception;  abstract class CONTROLLER extends FIELDS_READER {  	const TEMPLATE_KEY__TEMPLATE = '_template'; 	const TEMPLATE_KEY__IS_LOADED = '_isLoaded';  	private ?MODEL $_model; 	// using the prefix to prevent load this field 	protected array $__external;  	public function __construct( ?MODEL $model = null ) {  		parent::__construct();  		$this->_model     = $model; 		$this->__external = [];  		$this->_autoInitModel();  	}  	final public static function GetResourceInfo( Settings $settings, string $controllerClass = '' ): array {  		// using static for children support 		$controllerClass = ! $controllerClass ? 			static::class : 			$controllerClass;  		// e.g. $controllerClass = Example/Theme/Main/Example_Theme_Main_C 		$resourceInfo = [ 			'resourceName'         => '',// e.g. example--theme--main 			'relativePath'         => '',// e.g. Example/Theme/Main 			'relativeResourcePath' => '', // e.g. Example/Theme/Main/example--theme--main 		];  		$controllerSuffix = Settings::$ControllerSuffix;  		//  e.g. Example/Theme/Main/Example_Theme_Main 		$relativeControllerNamespace = $settings->getBlocksDirNamespace() ? 			str_replace( $settings->getBlocksDirNamespace() . '\\', '', $controllerClass ) : 			$controllerClass; 		$relativeControllerNamespace = substr( $relativeControllerNamespace, 0, mb_strlen( $relativeControllerNamespace ) - mb_strlen( $controllerSuffix ) );  		// e.g. Example_Theme_Main 		$phpBlockName = explode( '\\', $relativeControllerNamespace ); 		$phpBlockName = $phpBlockName[ count( $phpBlockName ) - 1 ];  		// e.g. example--theme--main (from Example_Theme_Main) 		$blockNameParts    = preg_split( '/(?=[A-Z])/', $phpBlockName, - 1, PREG_SPLIT_NO_EMPTY ); 		$blockResourceName = []; 		foreach ( $blockNameParts as $blockNamePart ) { 			$blockResourceName[] = strtolower( $blockNamePart ); 		} 		$blockResourceName = implode( '-', $blockResourceName ); 		$blockResourceName = str_replace( '_', '-', $blockResourceName );  		// e.g. Example/Theme/Main 		$relativePath = explode( '\\', $relativeControllerNamespace ); 		$relativePath = array_slice( $relativePath, 0, count( $relativePath ) - 1 ); 		$relativePath = implode( DIRECTORY_SEPARATOR, $relativePath );  		$resourceInfo['resourceName']         = $blockResourceName; 		$resourceInfo['relativePath']         = $relativePath; 		$resourceInfo['relativeResourcePath'] = $relativePath . DIRECTORY_SEPARATOR . $blockResourceName;  		return $resourceInfo;  	}  	// can be overridden if Controller doesn't have own twig (uses parents) 	public static function GetPathToTwigTemplate( Settings $settings, string $controllerClass = '' ): string { 		return self::GetResourceInfo( $settings, $controllerClass )['relativeResourcePath'] . $settings->getTwigExtension(); 	}  	// can be overridden if Controller doesn't have own model (uses parents) 	public static function GetModelClass(): string {  		$controllerClass = static::class; 		$modelClass      = rtrim( $controllerClass, Settings::$ControllerSuffix );  		return ( $modelClass !== $controllerClass && 		         class_exists( $modelClass, true ) && 		         is_subclass_of( $modelClass, MODEL::class ) ? 			$modelClass : 			'' ); 	}  	public static function OnLoad() {  	}  	final public function setModel( MODEL $model ): void { 		$this->_model = $model; 	}  	private function _getControllerField( string $fieldName ): ?CONTROLLER {  		$controller = null; 		$fieldsInfo = $this->_getFieldsInfo();  		if ( key_exists( $fieldName, $fieldsInfo ) ) {  			$controller = $this->{$fieldName};  			// prevent possible recursion by a mistake (if someone will create a field with self) 			// using static for children support 			$controller = ( $controller && 			                $controller instanceof CONTROLLER || 			                get_class( $controller ) !== static::class ) ? 				$controller : 				null;  		}  		return $controller;  	}  	public function getTemplateArgs( Settings $settings ): array {  		$modelFields  = $this->_model ? 			$this->_model->getFields() : 			[]; 		$templateArgs = [];  		foreach ( $modelFields as $modelFieldName => $modelFieldValue ) {  			$templateFieldName = ltrim( $modelFieldName, '_' );  			if ( ! $modelFieldValue instanceof MODEL ) {  				$templateArgs[ $templateFieldName ] = $modelFieldValue;  				continue; 			}  			$modelFieldController = $this->_getControllerField( $modelFieldName ); 			$modelFieldArgs       = []; 			$externalFieldArgs    = $this->__external[ $modelFieldName ] ?? [];  			if ( $modelFieldController ) {  				$modelFieldController->setModel( $modelFieldValue ); 				$modelFieldArgs = $modelFieldController->getTemplateArgs( $settings );  			}  			$templateArgs[ $templateFieldName ] = HELPER::ArrayMergeRecursive( $modelFieldArgs, $externalFieldArgs );  		}  		// using static for children support 		return array_merge( $templateArgs, [ 			self::TEMPLATE_KEY__TEMPLATE  => static::GetPathToTwigTemplate( $settings ), 			self::TEMPLATE_KEY__IS_LOADED => ( $this->_model && $this->_model->isLoaded() ), 		] ); 	}  	public function getDependencies( string $sourceClass = '' ): array {  		$dependencyClasses = []; 		$controllerFields  = $this->_getFieldsInfo();  		foreach ( $controllerFields as $fieldName => $fieldType ) {  			$dependencyController = $this->_getControllerField( $fieldName );  			if ( ! $dependencyController ) { 				continue; 			}  			$dependencyClass = get_class( $dependencyController );  			// 1. prevent the possible permanent recursion 			// 2. add only unique elements, because several fields can have the same type 			if ( ( $sourceClass && $dependencyClass === $sourceClass ) || 			     in_array( $dependencyClass, $dependencyClasses, true ) ) { 				continue; 			}  			// used static for child support 			$subDependencies = $dependencyController->getDependencies( static::class ); 			// only unique elements 			$subDependencies = array_diff( $subDependencies, $dependencyClasses );  			// sub dependencies are before the main dependency 			$dependencyClasses = array_merge( $dependencyClasses, $subDependencies, [ $dependencyClass, ] );  		}  		return $dependencyClasses; 	}  	// Can be overridden for declare a target model class and provide an IDE support 	public function getModel(): ?MODEL { 		return $this->_model; 	}  	private function _autoInitModel() {  		if ( $this->_model ) { 			return; 		}  		$modelClass = static::GetModelClass();  		try { 			$this->_model = $modelClass ? 				new $modelClass() : 				$this->_model; 		} catch ( Exception $ex ) { 			$this->_model = null; 		}  	}  } 
CONTROLLERTest.php
<?php  declare( strict_types=1 );  namespace LightSource\FrontBlocksFramework\Tests\unit;  use Codeception\Test\Unit; use LightSource\FrontBlocksFramework\{ 	CONTROLLER, 	MODEL, 	Settings };  class CONTROLLERTest extends Unit {  	private function _getModel( array $fields, bool $isLoaded = false ): MODEL {  		return new class ( $fields, $isLoaded ) extends MODEL {  			private array $_fields;  			public function __construct( array $fields, bool $isLoaded ) {  				parent::__construct();  				$this->_fields = $fields;  				if ( $isLoaded ) { 					$this->_load(); 				}  			}  			public function getFields(): array { 				return $this->_fields; 			}  		};  	}  	private function _getController( ?MODEL $model ): CONTROLLER { 		return new class ( $model ) extends CONTROLLER {  			public function __construct( ?MODEL $model = null ) { 				parent::__construct( $model ); 			}  		}; 	}  	private function _getTemplateArgsWithoutAdditional( array $templateArgs ) {  		$templateArgs = array_diff_key( $templateArgs, [ 			CONTROLLER::TEMPLATE_KEY__TEMPLATE  => '', 			CONTROLLER::TEMPLATE_KEY__IS_LOADED => '', 		] ); 		foreach ( $templateArgs as $templateKey => $templateValue ) {  			if ( ! is_array( $templateValue ) ) { 				continue; 			}  			$templateArgs[ $templateKey ] = $this->_getTemplateArgsWithoutAdditional( $templateValue );  		}  		return $templateArgs; 	}  	////  	public function testGetResourceInfoWithoutCamelCaseInBlockName() {  		$settings = new Settings(); 		$settings->setControllerSuffix( '_C' ); 		$settings->setBlocksDirNamespace( 'Namespace' );  		$this->assertEquals( [ 			'resourceName'         => 'block', 			'relativePath'         => 'Block', 			'relativeResourcePath' => 'Block/block', 		], CONTROLLER::GetResourceInfo( $settings, 'Namespace\\Block\\Block_C' ) );  	}  	public function testGetResourceInfoWithCamelCaseInBlockName() {  		$settings = new Settings(); 		$settings->setControllerSuffix( '_C' ); 		$settings->setBlocksDirNamespace( 'Namespace' );  		$this->assertEquals( [ 			'resourceName'         => 'block-name', 			'relativePath'         => 'BlockName', 			'relativeResourcePath' => 'BlockName/block-name', 		], CONTROLLER::GetResourceInfo( $settings, 'Namespace\\BlockName\\BlockName_C' ) );  	}  	public function testGetResourceInfoWithoutCamelCaseInTheme() {  		$settings = new Settings(); 		$settings->setControllerSuffix( '_C' ); 		$settings->setBlocksDirNamespace( 'Namespace' );  		$this->assertEquals( [ 			'resourceName'         => 'block--theme--main', 			'relativePath'         => 'Block/Theme/Main', 			'relativeResourcePath' => 'Block/Theme/Main/block--theme--main', 		], CONTROLLER::GetResourceInfo( $settings, 'Namespace\\Block\\Theme\\Main\\Block_Theme_Main_C' ) );  	}  	public function testGetResourceInfoWithCamelCaseInTheme() {  		$settings = new Settings(); 		$settings->setControllerSuffix( '_C' ); 		$settings->setBlocksDirNamespace( 'Namespace' );  		$this->assertEquals( [ 			'resourceName'         => 'block--theme--just-main', 			'relativePath'         => 'Block/Theme/JustMain', 			'relativeResourcePath' => 'Block/Theme/JustMain/block--theme--just-main', 		], CONTROLLER::GetResourceInfo( $settings, 'Namespace\\Block\\Theme\\JustMain\\Block_Theme_JustMain_C' ) );  	}  	////  	public function testGetTemplateArgsWhenModelContainsBuiltInTypes() {  		$settings   = new Settings(); 		$model      = $this->_getModel( [ 			'stringVariable' => 'just string', 		] ); 		$controller = $this->_getController( $model );  		$this->assertEquals( [ 			'stringVariable' => 'just string', 		], $this->_getTemplateArgsWithoutAdditional( $controller->getTemplateArgs( $settings ) ) );  	}  	public function testGetTemplateArgsWhenModelContainsAnotherModel() {  		$settings = new Settings();  		$modelA              = $this->_getModel( [ 			'_modelA' => 'just string from model a', 		] ); 		$modelB              = $this->_getModel( [ 			'_modelA' => $modelA, 			'_modelB' => 'just string from model b', 		] ); 		$controllerForModelA = $this->_getController( null ); 		$controllerForModelB = new class ( $modelB, $controllerForModelA ) extends CONTROLLER {  			protected $_modelA;  			public function __construct( ?MODEL $model = null, $controllerForModelA ) {  				parent::__construct( $model );  				$this->_modelA = $controllerForModelA;  			}  		};  		$this->assertEquals( [ 			'modelA' => [ 				'modelA' => 'just string from model a', 			], 			'modelB' => 'just string from model b', 		], $this->_getTemplateArgsWithoutAdditional( $controllerForModelB->getTemplateArgs( $settings ) ) );  	}  	public function testGetTemplateArgsWhenControllerContainsExternalArgs() {  		$settings = new Settings();  		$modelA              = $this->_getModel( [ 			'_additionalField' => '', 			'_modelA'          => 'just string from model a', 		] ); 		$modelB              = $this->_getModel( [ 			'_modelA' => $modelA, 			'_modelB' => 'just string from model b', 		] ); 		$controllerForModelA = $this->_getController( null ); 		$controllerForModelB = new class ( $modelB, $controllerForModelA ) extends CONTROLLER {  			protected $_modelA;  			public function __construct( ?MODEL $model = null, $controllerForModelA ) {  				parent::__construct( $model );  				$this->_modelA               = $controllerForModelA; 				$this->__external['_modelA'] = [ 					'additionalField' => 'additionalValue', 				];  			}  		};  		$this->assertEquals( [ 			'modelA' => [ 				'additionalField' => 'additionalValue', 				'modelA'          => 'just string from model a', 			], 			'modelB' => 'just string from model b', 		], $this->_getTemplateArgsWithoutAdditional( $controllerForModelB->getTemplateArgs( $settings ) ) );  	}  	public function testGetTemplateArgsContainsAdditionalFields() {  		$settings   = new Settings(); 		$model      = $this->_getModel( [] ); 		$controller = $this->_getController( $model );  		$this->assertEquals( [ 			CONTROLLER::TEMPLATE_KEY__TEMPLATE, 			CONTROLLER::TEMPLATE_KEY__IS_LOADED, 		], array_keys( $controller->getTemplateArgs( $settings ) ) );  	}  	public function testGetTemplateArgsWhenAdditionalIsLoadedIsFalse() {  		$settings   = new Settings(); 		$model      = $this->_getModel( [] ); 		$controller = $this->_getController( $model );  		$actual = array_intersect_key( $controller->getTemplateArgs( $settings ), [ CONTROLLER::TEMPLATE_KEY__IS_LOADED => '', ] );  		$this->assertEquals( [ CONTROLLER::TEMPLATE_KEY__IS_LOADED => false, ], $actual );  	}  	public function testGetTemplateArgsWhenAdditionalIsLoadedIsTrue() {  		$settings   = new Settings(); 		$model      = $this->_getModel( [], true ); 		$controller = $this->_getController( $model );  		$actual = array_intersect_key( $controller->getTemplateArgs( $settings ), [ CONTROLLER::TEMPLATE_KEY__IS_LOADED => '', ] );  		$this->assertEquals( [ CONTROLLER::TEMPLATE_KEY__IS_LOADED => true, ], $actual );  	}  	public function testGetTemplateArgsAdditionalTemplateIsRight() {  		$settings   = new Settings(); 		$model      = $this->_getModel( [] ); 		$controller = $this->_getController( $model );  		$actual = array_intersect_key( $controller->getTemplateArgs( $settings ), [ CONTROLLER::TEMPLATE_KEY__TEMPLATE => '', ] );  		$this->assertEquals( [ 			CONTROLLER::TEMPLATE_KEY__TEMPLATE => $controller::GetPathToTwigTemplate( $settings ), 		], $actual ); 	}  	////  	public function testGetDependencies() {  		$controllerA = $this->_getController( null );  		$controllerB = new class ( null, $controllerA ) extends CONTROLLER {  			protected $_controllerA;  			public function __construct( ?MODEL $model = null, $controllerA ) {  				parent::__construct( $model );  				$this->_controllerA = $controllerA;  			}  		};  		$this->assertEquals( [ 			get_class( $controllerA ), 		], $controllerB->getDependencies() );  	}  	public function testGetDependenciesWithSubDependencies() {  		$controllerA = new class extends CONTROLLER {  			public function getDependencies( string $sourceClass = '' ): array { 				return [ 					'A', 				]; 			}  		};  		$controllerB = new class ( null, $controllerA ) extends CONTROLLER {  			protected $_controllerA;  			public function __construct( ?MODEL $model = null, $controllerA ) {  				parent::__construct( $model );  				$this->_controllerA = $controllerA;  			}  		};  		$this->assertEquals( [ 			'A', 			get_class( $controllerA ), 		], $controllerB->getDependencies() );  	}  	public function testGetDependenciesWithSubDependenciesRecursively() {  		$controllerA = new class extends CONTROLLER {  			public function getDependencies( string $sourceClass = '' ): array { 				return [ 					'A', 				]; 			}  		};  		$controllerB = new class ( null, $controllerA ) extends CONTROLLER {  			protected $_controllerA;  			public function __construct( ?MODEL $model = null, $controllerA ) {  				parent::__construct( $model );  				$this->_controllerA = $controllerA;  			}  		};  		$controllerC = new class ( null, $controllerB ) extends CONTROLLER {  			protected $_controllerB;  			public function __construct( ?MODEL $model = null, $controllerB ) {  				parent::__construct( $model );  				$this->_controllerB = $controllerB;  			}  		};  		$this->assertEquals( [ 			'A', 			get_class( $controllerA ), 			get_class( $controllerB ), 		], $controllerC->getDependencies() );  	}  	public function testGetDependenciesWithSubDependenciesInOrderWhenSubBeforeMainDependency() {  		$controllerA = new class extends CONTROLLER {  			public function getDependencies( string $sourceClass = '' ): array { 				return [ 					'A', 				]; 			}  		};  		$controllerB = new class ( null, $controllerA ) extends CONTROLLER {  			protected $_controllerA;  			public function __construct( ?MODEL $model = null, $controllerA ) {  				parent::__construct( $model );  				$this->_controllerA = $controllerA;  			}  		};  		$this->assertEquals( [ 			'A', 			get_class( $controllerA ), 		], $controllerB->getDependencies() );  	}  	public function testGetDependenciesWithSubDependenciesWhenBlocksAreDependentFromEachOther() {  		$controllerA = new class extends CONTROLLER {  			protected $_controllerB;  			public function setControllerB( $controllerB ) { 				$this->_controllerB = $controllerB; 			}  		};  		$controllerB = new class ( null, $controllerA ) extends CONTROLLER {  			protected $_controllerA;  			public function __construct( ?MODEL $model = null, $controllerA ) {  				parent::__construct( $model );  				$this->_controllerA = $controllerA;  			}  		};  		$controllerA->setControllerB( $controllerB );  		$this->assertEquals( [ 			get_class( $controllerA ), 		], $controllerB->getDependencies() );  	}  	public function testGetDependenciesWithoutDuplicatesWhenSeveralWithOneType() {  		$controllerA = $this->_getController( null );  		$controllerB = new class ( null, $controllerA ) extends CONTROLLER {  			protected $_controllerA; 			protected $_controllerAA; 			protected $_controllerAAA;  			public function __construct( ?MODEL $model = null, $controllerA ) {  				parent::__construct( $model );  				$this->_controllerA   = $controllerA; 				$this->_controllerAA  = $controllerA; 				$this->_controllerAAA = $controllerA;  			}  		};  		$this->assertEquals( [ 			get_class( $controllerA ), 		], $controllerB->getDependencies() );  	}  	////  	public function testAutoInitModel() {  		$modelClass      = str_replace( [ '::', '\\' ], '_', __METHOD__ ); 		$controllerClass = $modelClass . Settings::$ControllerSuffix; 		eval( 'class ' . $modelClass . ' extends ' . MODEL::class . ' {}' ); 		eval( 'class ' . $controllerClass . ' extends ' . CONTROLLER::class . ' {}' ); 		$controller = new $controllerClass();  		$actualModelClass = $controller->getModel() ? 			get_class( $controller->getModel() ) : 			'';  		$this->assertEquals( $modelClass, $actualModelClass );  	}  	public function testAutoInitModelWhenModelHasWrongClass() {  		$modelClass      = str_replace( [ '::', '\\' ], '_', __METHOD__ ); 		$controllerClass = $modelClass . Settings::$ControllerSuffix; 		eval( 'class ' . $modelClass . ' {}' ); 		eval( 'class ' . $controllerClass . ' extends ' . CONTROLLER::class . ' {}' ); 		$controller = new $controllerClass();  		$this->assertEquals( null, $controller->getModel() );  	}  } 

Settings

Вспомогательный класс, думаю что не нуждается в комментариях

Settings.php
<?php  declare( strict_types=1 );  namespace LightSource\FrontBlocksFramework;  class Settings {  	public static string $ControllerSuffix = '_C';  	private string $_blocksDirPath; 	private string $_blocksDirNamespace; 	private array $_twigArgs; 	private string $_twigExtension; 	private $_errorCallback;  	public function __construct() {  		$this->_blocksDirPath      = ''; 		$this->_blocksDirNamespace = ''; 		$this->_twigArgs           = [ 			// will generate exception if a var doesn't exist instead of replace to NULL 			'strict_variables' => true, 			// disable autoescape to prevent break data 			'autoescape'       => false, 		]; 		$this->_twigExtension      = '.twig'; 		$this->_errorCallback      = null;  	}  	public function setBlocksDirPath( string $blocksDirPath ): void { 		$this->_blocksDirPath = $blocksDirPath; 	}  	public function setBlocksDirNamespace( string $blocksDirNamespace ): void { 		$this->_blocksDirNamespace = $blocksDirNamespace; 	}  	public function setTwigArgs( array $twigArgs ): void { 		$this->_twigArgs = array_merge( $this->_twigArgs, $twigArgs ); 	}  	public function setErrorCallback( ?callable $errorCallback ): void { 		$this->_errorCallback = $errorCallback; 	}  	public function setTwigExtension( string $twigExtension ): void { 		$this->_twigExtension = $twigExtension; 	}  	public function setControllerSuffix( string $controllerSuffix ): void { 		$this->_controllerSuffix = $controllerSuffix; 	}  	public function getBlocksDirPath(): string { 		return $this->_blocksDirPath; 	}  	public function getBlocksDirNamespace(): string { 		return $this->_blocksDirNamespace; 	}  	public function getTwigArgs(): array { 		return $this->_twigArgs; 	}  	public function getTwigExtension(): string { 		return $this->_twigExtension; 	}  	public function callErrorCallback( array $errors ): void {  		if ( ! is_callable( $this->_errorCallback ) ) { 			return; 		}  		call_user_func_array( $this->_errorCallback, [ $errors, ] );  	}  } 

Twig

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

Twig.php
<?php  declare( strict_types=1 );  namespace LightSource\FrontBlocksFramework;  use Exception; use Twig\Environment; use Twig\Loader\FilesystemLoader; use Twig\Loader\LoaderInterface; use Twig\TwigFilter; use Twig\TwigFunction;  class Twig {  	private ?LoaderInterface $_twigLoader; 	private ?Environment $_twigEnvironment; 	private Settings $_settings;  	public function __construct( Settings $settings, ?LoaderInterface $twigLoader = null ) {  		$this->_twigEnvironment = null; 		$this->_settings        = $settings; 		$this->_twigLoader      = $twigLoader;  		$this->_init();  	}  	// e.g for extend a twig with adding a new filter 	public function getEnvironment(): ?Environment { 		return $this->_twigEnvironment; 	}  	private function _extendTwig(): void {  		$this->_twigEnvironment->addFilter( new TwigFilter( '_merge', function ( $source, $additional ) { 			return HELPER::ArrayMergeRecursive( $source, $additional ); 		} ) ); 		$this->_twigEnvironment->addFunction( new TwigFunction( '_include', function ( $block, $args = [] ) {  			$block = HELPER::ArrayMergeRecursive( $block, $args );  			return $block[ CONTROLLER::TEMPLATE_KEY__IS_LOADED ] ? 				$this->render( $block[ CONTROLLER::TEMPLATE_KEY__TEMPLATE ], $block ) : 				''; 		} ) );  	}  	private function _init(): void {  		try {  			$this->_twigLoader      = ! $this->_twigLoader ? 				new FilesystemLoader( $this->_settings->getBlocksDirPath() ) : 				$this->_twigLoader; 			$this->_twigEnvironment = new Environment( $this->_twigLoader, $this->_settings->getTwigArgs() );  		} catch ( Exception $ex ) {  			$this->_twigEnvironment = null;  			$this->_settings->callErrorCallback( [ 				'message' => $ex->getMessage(), 				'file'    => $ex->getFile(), 				'line'    => $ex->getLine(), 				'trace'   => $ex->getTraceAsString(), 			] );  			return;  		}  		$this->_extendTwig();  	}  	public function render( string $template, array $args = [], bool $isPrint = false ): string {  		$html = '';  		// twig isn't loaded 		if ( is_null( $this->_twigEnvironment ) ) { 			return $html; 		}  		try { 			// will generate ean exception if a template doesn't exist OR broken 			// also if a var doesn't exist (if using a 'strict_variables' flag, see Twig_Environment->__construct) 			$html .= $this->_twigEnvironment->render( $template, $args ); 		} catch ( Exception $ex ) {  			$html = '';  			$this->_settings->callErrorCallback( [ 				'message'  => $ex->getMessage(), 				'file'     => $ex->getFile(), 				'line'     => $ex->getLine(), 				'trace'    => $ex->getTraceAsString(), 				'template' => $template, 			] );  		}  		if ( $isPrint ) { 			echo $html; 		}  		return $html;  	}  } 
TwigTest.php
<?php  declare( strict_types=1 );  namespace LightSource\FrontBlocksFramework\Tests\unit;  use Codeception\Test\Unit; use Exception; use LightSource\FrontBlocksFramework\CONTROLLER; use LightSource\FrontBlocksFramework\Settings; use LightSource\FrontBlocksFramework\Twig; use Twig\Loader\ArrayLoader;  class TwigTest extends Unit {  	private function _renderBlock( array $blocks, string $renderBlock, array $renderArgs = [] ): string {  		$twigLoader = new ArrayLoader( $blocks ); 		$settings   = new Settings();  		$twig    = new Twig( $settings, $twigLoader ); 		$content = '';  		try {  			$content = $twig->render( $renderBlock, $renderArgs );  		} catch ( Exception $ex ) { 			$this->fail( 'Twig render exception, ' . $ex->getMessage() ); 		}  		return $content; 	}  	public function testExtendTwigIncludeFunctionWhenBlockIsLoaded() {  		$blocks      = [ 			'block-a.twig' => '{{ _include(blockB) }}', 			'block-b.twig' => 'block-b content', 		]; 		$renderBlock = 'block-a.twig'; 		$renderArgs  = [ 			'blockB' => [ 				CONTROLLER::TEMPLATE_KEY__TEMPLATE  => 'block-b.twig', 				CONTROLLER::TEMPLATE_KEY__IS_LOADED => true, 			], 		];  		$this->assertEquals( 'block-b content', $this->_renderBlock( $blocks, $renderBlock, $renderArgs ) );  	}  	public function testExtendTwigIncludeFunctionWhenBlockNotLoaded() {  		$blocks      = [ 			'block-a.twig' => '{{ _include(blockB) }}', 			'block-b.twig' => 'block-b content', 		]; 		$renderBlock = 'block-a.twig'; 		$renderArgs  = [ 			'blockB' => [ 				CONTROLLER::TEMPLATE_KEY__TEMPLATE  => 'block-b.twig', 				CONTROLLER::TEMPLATE_KEY__IS_LOADED => false, 			], 		];  		$this->assertEquals( '', $this->_renderBlock( $blocks, $renderBlock, $renderArgs ) );  	}  	public function testExtendTwigIncludeFunctionWhenArgsPassed() {  		$blocks      = [ 			'block-a.twig' => '{{ _include(blockB, {classes:["test-class",],}) }}', 			'block-b.twig' => '{{ classes|join(" ") }}', 		]; 		$renderBlock = 'block-a.twig'; 		$renderArgs  = [ 			'blockB' => [ 				CONTROLLER::TEMPLATE_KEY__TEMPLATE  => 'block-b.twig', 				CONTROLLER::TEMPLATE_KEY__IS_LOADED => true, 				'classes'                           => [ 'own-class', ], 			], 		];  		$this->assertEquals( 'own-class test-class', $this->_renderBlock( $blocks, $renderBlock, $renderArgs ) );  	}  	public function testExtendTwigMergeFilter() {  		$blocks      = [ 			'block-a.twig' => '{{ {"array":["a",],}|_merge({"array":["b",],}).array|join(" ") }}', 		]; 		$renderBlock = 'block-a.twig'; 		$renderArgs  = [];  		$this->assertEquals( 'a b', $this->_renderBlock( $blocks, $renderBlock, $renderArgs ) );   	}  } 

Blocks

Это наш объединяющий класс.

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

Метод renderBlock принимает объект контроллера и производит рендер блока, передавая в twig шаблон аргументы из метода CONROLLER->getTemplateArgs выше. Также добавляет класс используемого контроллера и классы всех его зависимостей в список использованных блоков, что позволит нам далее получить используемый css и js.

Ну и наконец метод getUsedResources используя список выше и статический метод CONTROLLER::GetResourceInfo позволяет нам после рендера блоков получить используемый css и js код, объединенный в правильной последовательности, т.е. с учетом всех зависимостей./

Blocks.php
<?php  declare( strict_types=1 );  namespace LightSource\FrontBlocksFramework;  class Blocks {  	private array $_loadedControllerClasses; 	private array $_usedControllerClasses; 	private Settings $_settings; 	private Twig $_twig;  	public function __construct( Settings $settings ) {  		$this->_loadedControllerClasses = []; 		$this->_usedControllerClasses   = []; 		$this->_settings                = $settings; 		$this->_twig                    = new Twig( $settings );  	}  	final public function getLoadedControllerClasses(): array { 		return $this->_loadedControllerClasses; 	}  	final public function getUsedControllerClasses(): array { 		return $this->_usedControllerClasses; 	}  	final public function getSettings(): Settings { 		return $this->_settings; 	}  	final public function getTwig(): Twig { 		return $this->_twig; 	}  	final public function getUsedResources( string $extension, bool $isIncludeSource = false ): string {  		$resourcesContent = '';  		foreach ( $this->_usedControllerClasses as $usedControllerClass ) {  			$getResourcesInfoCallback = [ $usedControllerClass, 'GetResourceInfo' ];  			if ( ! is_callable( $getResourcesInfoCallback ) ) {  				$this->_settings->callErrorCallback( [ 					'message' => "Controller class doesn't exist", 					'class'   => $usedControllerClass, 				] );  				continue; 			}  			$resourceInfo = call_user_func_array( $getResourcesInfoCallback, [ 				$this->_settings, 			] );  			$pathToResourceFile = $this->_settings->getBlocksDirPath() . DIRECTORY_SEPARATOR . $resourceInfo['relativeResourcePath'] . $extension;  			if ( ! is_file( $pathToResourceFile ) ) { 				continue; 			}  			$resourcesContent .= $isIncludeSource ? 				"\n/* " . $resourceInfo['resourceName'] . " */\n" : 				'';  			$resourcesContent .= file_get_contents( $pathToResourceFile );  		}  		return $resourcesContent; 	}  	private function _loadController( string $phpClass, array $debugArgs ): bool {  		$isLoaded = false;  		if ( ! class_exists( $phpClass, true ) || 		     ! is_subclass_of( $phpClass, CONTROLLER::class ) ) {  			$this->_settings->callErrorCallback( [ 				'message' => "Class doesn't exist or doesn't child", 				'args'    => $debugArgs, 			] );  			return $isLoaded; 		}  		call_user_func( [ $phpClass, 'OnLoad' ] );  		return true; 	}  	private function _loadControllers( string $directory, string $namespace, array $controllerFileNames ): void {  		foreach ( $controllerFileNames as $controllerFileName ) {  			$phpFile   = implode( DIRECTORY_SEPARATOR, [ $directory, $controllerFileName ] ); 			$phpClass  = implode( '\\', [ $namespace, str_replace( '.php', '', $controllerFileName ), ] ); 			$debugArgs = [ 				'directory' => $directory, 				'namespace' => $namespace, 				'phpFile'   => $phpFile, 				'phpClass'  => $phpClass, 			];  			if ( ! $this->_loadController( $phpClass, $debugArgs ) ) { 				continue; 			}  			$this->_loadedControllerClasses[] = $phpClass;  		}  	}  	private function _loadDirectory( string $directory, string $namespace ): void {  		// exclude ., .. 		$fs = array_diff( scandir( $directory ), [ '.', '..' ] );  		$controllerFilePreg = '/' . Settings::$ControllerSuffix . '.php$/';  		$controllerFileNames = HELPER::ArrayFilter( $fs, function ( $f ) use ( $controllerFilePreg ) { 			return ( 1 === preg_match( $controllerFilePreg, $f ) ); 		}, false ); 		$subDirectoryNames   = HELPER::ArrayFilter( $fs, function ( $f ) { 			return false === strpos( $f, '.' ); 		}, false );  		foreach ( $subDirectoryNames as $subDirectoryName ) {  			$subDirectory = implode( DIRECTORY_SEPARATOR, [ $directory, $subDirectoryName ] ); 			$subNamespace = implode( '\\', [ $namespace, $subDirectoryName ] );  			$this->_loadDirectory( $subDirectory, $subNamespace );  		}  		$this->_loadControllers( $directory, $namespace, $controllerFileNames );  	}  	final public function loadAll(): void {  		$directory = $this->_settings->getBlocksDirPath(); 		$namespace = $this->_settings->getBlocksDirNamespace();  		$this->_loadDirectory( $directory, $namespace );  	}  	final public function renderBlock( CONTROLLER $controller, array $args = [], bool $isPrint = false ): string {  		$dependencies                 = array_merge( $controller->getDependencies(), [ get_class( $controller ), ] ); 		$newDependencies              = array_diff( $dependencies, $this->_usedControllerClasses ); 		$this->_usedControllerClasses = array_merge( $this->_usedControllerClasses, $newDependencies );  		$templateArgs = $controller->getTemplateArgs( $this->_settings ); 		$templateArgs = HELPER::ArrayMergeRecursive( $templateArgs, $args );  		return $this->_twig->render( $templateArgs[ CONTROLLER::TEMPLATE_KEY__TEMPLATE ], $templateArgs, $isPrint ); 	}  } 
BlocksTest.php
<?php  declare( strict_types=1 );  namespace LightSource\FrontBlocksFramework\Tests\unit;  use Codeception\Test\Unit; use Exception; use LightSource\FrontBlocksFramework\Blocks; use LightSource\FrontBlocksFramework\CONTROLLER; use LightSource\FrontBlocksFramework\MODEL; use LightSource\FrontBlocksFramework\Settings; use LightSource\FrontBlocksFramework\Twig; use org\bovigo\vfs\vfsStream; use org\bovigo\vfs\vfsStreamDirectory;  class BlocksTest extends Unit {  	private function _getBlocks( string $namespace, vfsStreamDirectory $rootDirectory, array $structure, array $usedControllerClasses = [] ): ?Blocks {  		vfsStream::create( $structure, $rootDirectory );  		$settings = new Settings(); 		$settings->setBlocksDirNamespace( $namespace ); 		$settings->setBlocksDirPath( $rootDirectory->url() );  		$twig = $this->make( Twig::class, [ 			'render' => function ( string $template, array $args = [], bool $isPrint = false ): string { 				return ''; 			}, 		] );  		try { 			$blocks = $this->make( Blocks::class, [ 				'_loadedControllerClasses' => [], 				'_usedControllerClasses'   => $usedControllerClasses, 				'_twig'                    => $twig, 				'_settings'                => $settings, 			] );  		} catch ( Exception $ex ) { 			$this->fail( "Can't make Blocks stub, " . $ex->getMessage() ); 		}  		$blocks->loadAll();  		return $blocks; 	}  	// get a unique namespace depending on a test method to prevent affect other tests 	private function _getUniqueControllerNamespaceWithAutoloader( string $methodConstant, vfsStreamDirectory $rootDirectory ): string {  		$namespace = str_replace( '::', '_', $methodConstant );  		spl_autoload_register( function ( $class ) use ( $rootDirectory, $namespace ) {  			$targetNamespace = $namespace . '\\'; 			if ( 0 !== strpos( $class, $targetNamespace ) ) { 				return; 			}  			$relativePathToFile = str_replace( $targetNamespace, '', $class ); 			$relativePathToFile = str_replace( '\\', '/', $relativePathToFile );  			$absPathToFile = $rootDirectory->url() . DIRECTORY_SEPARATOR . $relativePathToFile . '.php';  			include_once $absPathToFile;  		} );  		return $namespace; 	}  	// get a unique directory name depending on a test method to prevent affect other tests 	private function _getUniqueDirectory( string $methodConstant ): vfsStreamDirectory {  		$dirName = str_replace( [ ':', '\\' ], '_', $methodConstant );  		return vfsStream::setup( $dirName ); 	}  	private function _getControllerClassFile( string $namespace, string $class ): string {  		$vendorControllerClass = '\LightSource\FrontBlocksFramework\CONTROLLER';  		return '<?php namespace ' . $namespace . '; class ' . $class . ' extends ' . $vendorControllerClass . ' {}'; 	}  	private function _getController( array $dependencies = [] ) { 		return new class ( null, $dependencies ) extends CONTROLLER {  			private array $_dependencies;  			public function __construct( ?MODEL $model = null, array $dependencies ) {  				parent::__construct( $model ); 				$this->_dependencies = $dependencies;  			}  			function getDependencies( string $sourceClass = '' ): array { 				return $this->_dependencies; 			}  			function getTemplateArgs( Settings $settings ): array { 				return [ 					CONTROLLER::TEMPLATE_KEY__TEMPLATE => '', 				]; 			}   		}; 	}  	////  	public function testLoadAllControllersWithPrefix() {  		// fixme 		$rootDirectory = $this->_getUniqueDirectory( __METHOD__ ); 		$namespace     = $this->_getUniqueControllerNamespaceWithAutoloader( __METHOD__, $rootDirectory ); 		$blocks        = $this->_getBlocks( $namespace, $rootDirectory, [ 			'Block' => [ 				'Block_C.php' => $this->_getControllerClassFile( "{$namespace}\Block", 'Block_C' ), 			], 		] );  		$this->assertEquals( [ 			"{$namespace}\Block\Block_C", 		], $blocks->getLoadedControllerClasses() );  	}  	public function testLoadAllIgnoreControllersWithoutPrefix() {  		$rootDirectory = $this->_getUniqueDirectory( __METHOD__ ); 		$namespace     = $this->_getUniqueControllerNamespaceWithAutoloader( __METHOD__, $rootDirectory ); 		$blocks        = $this->_getBlocks( $namespace, $rootDirectory, [ 			'Block' => [ 				'Block.php' => $this->_getControllerClassFile( "{$namespace}\Block", 'Block' ), 			], 		] );  		$this->assertEquals( [], $blocks->getLoadedControllerClasses() );  	}  	public function testLoadAllIgnoreWrongControllers() {  		$rootDirectory = $this->_getUniqueDirectory( __METHOD__ ); 		$namespace     = $this->_getUniqueControllerNamespaceWithAutoloader( __METHOD__, $rootDirectory ); 		$blocks        = $this->_getBlocks( $namespace, $rootDirectory, [ 			'Block' => [ 				'Block_C.php' => $this->_getControllerClassFile( "{$namespace}\Block", 'WrongBlock_C' ), 			], 		] );  		$this->assertEquals( [], $blocks->getLoadedControllerClasses() );  	}  	////  	public function testRenderBlockAddsControllerToUsedList() {  		$rootDirectory = $this->_getUniqueDirectory( __METHOD__ ); 		$namespace     = $this->_getUniqueControllerNamespaceWithAutoloader( __METHOD__, $rootDirectory ); 		$blocks        = $this->_getBlocks( $namespace, $rootDirectory, [] ); 		$controller    = $this->_getController();  		$blocks->renderBlock( $controller );  		$this->assertEquals( [ 			get_class( $controller ), 		], $blocks->getUsedControllerClasses() );  	}  	public function testRenderBlockAddsControllerDependenciesToUsedList() {  		$rootDirectory = $this->_getUniqueDirectory( __METHOD__ ); 		$namespace     = $this->_getUniqueControllerNamespaceWithAutoloader( __METHOD__, $rootDirectory ); 		$blocks        = $this->_getBlocks( $namespace, $rootDirectory, [] ); 		$controller    = $this->_getController( [ 'A', ] );  		$blocks->renderBlock( $controller );  		$this->assertEquals( [ 			'A', 			get_class( $controller ), 		], $blocks->getUsedControllerClasses() );  	}  	public function testRenderBlockAddsDependenciesBeforeControllerToUsedList() {  		$rootDirectory = $this->_getUniqueDirectory( __METHOD__ ); 		$namespace     = $this->_getUniqueControllerNamespaceWithAutoloader( __METHOD__, $rootDirectory ); 		$blocks        = $this->_getBlocks( $namespace, $rootDirectory, [] ); 		$controller    = $this->_getController( [ 'A', ] );  		$blocks->renderBlock( $controller );  		$this->assertEquals( [ 			'A', 			get_class( $controller ), 		], $blocks->getUsedControllerClasses() );  	}  	public function testRenderBlockIgnoreDuplicateControllerWhenAddsToUsedList() {  		$rootDirectory = $this->_getUniqueDirectory( __METHOD__ ); 		$namespace     = $this->_getUniqueControllerNamespaceWithAutoloader( __METHOD__, $rootDirectory ); 		$blocks        = $this->_getBlocks( $namespace, $rootDirectory, [] ); 		$controllerA   = $this->_getController();  		$blocks->renderBlock( $controllerA ); 		$blocks->renderBlock( $controllerA );  		$this->assertEquals( [ 			get_class( $controllerA ), 		], $blocks->getUsedControllerClasses() );  	}  	public function testRenderBlockIgnoreDuplicateControllerDependenciesWhenAddsToUsedList() {  		$rootDirectory = $this->_getUniqueDirectory( __METHOD__ ); 		$namespace     = $this->_getUniqueControllerNamespaceWithAutoloader( __METHOD__, $rootDirectory ); 		$blocks        = $this->_getBlocks( $namespace, $rootDirectory, [] ); 		$controllerA   = $this->_getController( [ 'A', ] ); 		$controllerB   = $this->_getController( [ 'A', ] );  		$blocks->renderBlock( $controllerA ); 		$blocks->renderBlock( $controllerB );  		$this->assertEquals( [ 			'A', 			get_class( $controllerA ),// $controllerB has the same class 		], $blocks->getUsedControllerClasses() );  	}  	////  	public function testGetUsedResourcesWhenBlockWithResources() {  		$rootDirectory = $this->_getUniqueDirectory( __METHOD__ ); 		$namespace     = $this->_getUniqueControllerNamespaceWithAutoloader( __METHOD__, $rootDirectory ); 		$blocks        = $this->_getBlocks( $namespace, $rootDirectory, [ 			'Block' => [ 				'Block_C.php' => $this->_getControllerClassFile( "{$namespace}\Block", 'Block_C' ), 				'block.css'   => 'just css code', 			], 		], [ 			"{$namespace}\Block\Block_C", 		] );  		$this->assertEquals( 'just css code', 			$blocks->getUsedResources( '.css', false ) );  	}  	public function testGetUsedResourcesWhenBlockWithoutResources() {  		$rootDirectory = $this->_getUniqueDirectory( __METHOD__ ); 		$namespace     = $this->_getUniqueControllerNamespaceWithAutoloader( __METHOD__, $rootDirectory ); 		$blocks        = $this->_getBlocks( $namespace, $rootDirectory, [ 			'Block' => [ 				'Block_C.php' => $this->_getControllerClassFile( "{$namespace}\Block", 'Block_C' ), 			], 		], [ 			"{$namespace}\Block\Block_C", 		] );  		$this->assertEquals( '', 			$blocks->getUsedResources( '.css', false ) );  	}  	public function testGetUsedResourcesWhenSeveralBlocks() {  		$rootDirectory = $this->_getUniqueDirectory( __METHOD__ ); 		$namespace     = $this->_getUniqueControllerNamespaceWithAutoloader( __METHOD__, $rootDirectory ); 		$blocks        = $this->_getBlocks( $namespace, $rootDirectory, [ 			'BlockA' => [ 				'BlockA_C.php' => $this->_getControllerClassFile( "{$namespace}\BlockA", 'BlockA_C' ), 				'block-a.css'  => 'css code for a', 			], 			'BlockB' => [ 				'BlockB_C.php' => $this->_getControllerClassFile( "{$namespace}\BlockB", 'BlockB_C' ), 				'block-b.css'  => 'css code for b', 			], 		], [ 			"{$namespace}\BlockA\BlockA_C", 			"{$namespace}\BlockB\BlockB_C", 		] );  		$this->assertEquals( 'css code for acss code for b', 			$blocks->getUsedResources( '.css', false ) );  	}  	public function testGetUsedResourcesWithIncludedSource() {  		$rootDirectory = $this->_getUniqueDirectory( __METHOD__ ); 		$namespace     = $this->_getUniqueControllerNamespaceWithAutoloader( __METHOD__, $rootDirectory ); 		$blocks        = $this->_getBlocks( $namespace, $rootDirectory, [ 			'SimpleBlock' => [ 				'SimpleBlock_C.php' => $this->_getControllerClassFile( "{$namespace}\SimpleBlock", 'SimpleBlock_C' ), 				'simple-block.css'  => 'css code', 			], 		], [ 			"{$namespace}\SimpleBlock\SimpleBlock_C", 		] );  		$this->assertEquals( "\n/* simple-block */\ncss code", 			$blocks->getUsedResources( '.css', true ) );  	}  } 

Вот и все, теперь осталось объединить эти части и наш мини-фреймворк готов. Момент публикации composer пакета я опущу, т.к. это довольно простая задача и если вам будет интересно то вы без труда найдете информацию по этому поводу.

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

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

Создаем блоки для теста, BlockA и BlockC будут независимыми блоками, BlockB будет содержкать BlockC.

BlockA

BlockA.php
<?php  namespace LightSource\FrontBlocksExample\BlockA;  use LightSource\FrontBlocksFramework\MODEL;  class BlockA extends MODEL {  	protected string $_name;  	public function load() {  		parent::_load(); 		$this->_name = 'I\'m BlockA';  	}  } 
BlockA_C.php

/sp

<?php  namespace LightSource\FrontBlocksExample\BlockA;  use LightSource\FrontBlocksFramework\Blocks; use LightSource\FrontBlocksFramework\CONTROLLER;  class BlockA_C extends CONTROLLER {  	public function getModel(): ?BlockA { 		/** @noinspection PhpIncompatibleReturnTypeInspection */ 		return parent::getModel(); 	}  } 
block-a.twig

/

<div class="block-a">     {{ name }} </div>
block-a.css

Bl

.block-a {     color: green;     border:1px solid green;     padding: 10px; } 

BlockB

BlockB.php
<?php  namespace LightSource\FrontBlocksExample\BlockB;  use LightSource\FrontBlocksExample\BlockC\BlockC; use LightSource\FrontBlocksFramework\MODEL;  class BlockB extends MODEL {  	protected string $_name; 	protected BlockC $_blockC;  	public function __construct() {  		parent::__construct();  		$this->_blockC = new BlockC();  	}  	public function load() {  		parent::_load(); 		$this->_name = 'I\'m BlockB, I contain another block'; 		$this->_blockC->load();  	}  } 
BlockB_C.php
<?php  namespace LightSource\FrontBlocksExample\BlockB;  use LightSource\FrontBlocksExample\BlockC\BlockC_C; use LightSource\FrontBlocksFramework\CONTROLLER;  class BlockB_C extends CONTROLLER {  	protected BlockC_C $_blockC;  	public function getModel(): ?BlockB { 		/** @noinspection PhpIncompatibleReturnTypeInspection */ 		return parent::getModel(); 	}  } 
block-b.twig
<div class="block-b">      <p class="block-b__name">{{ name }}</p>      {{ _include(blockC) }}  </div>
block-b.css

Blo

.block-b {     color: orange;     border: 1px solid orange;     padding: 10px; }  .block-b__name {     margin: 0 0 10px;     line-height: 1.5; } 

BlocksC

BlockC.php
<?php  namespace LightSource\FrontBlocksExample\BlockC;  use LightSource\FrontBlocksFramework\MODEL;  class BlockC extends MODEL {  	protected string $_name;  	public function load() {  		parent::_load(); 		$this->_name = 'I\'m BlockC';  	}  } 
BlockC_C.php

/

<?php  namespace LightSource\FrontBlocksExample\BlockC;  use LightSource\FrontBlocksFramework\CONTROLLER;  class BlockC_C extends CONTROLLER {  	public function getModel(): ?BlockC { 		/** @noinspection PhpIncompatibleReturnTypeInspection */ 		return parent::getModel(); 	}  } 

Подключаем наш пакет и рендерим блоки

block-c.twig
<div class="block-c">     {{ name }} </div>
block-c.css
.block-c {     color: black;     border: 1px solid black;     padding: 10px; } 

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

example.php
<?php  use LightSource\FrontBlocksExample\{ 	BlockA\BlockA_C, 	BlockB\BlockB_C, }; use LightSource\FrontBlocksFramework\{ 	Blocks, 	Settings };  require_once __DIR__ . '/vendors/vendor/autoload.php';  //// settings  $settings = new Settings(); $settings->setBlocksDirNamespace( 'LightSource\FrontBlocksExample' ); $settings->setBlocksDirPath( __DIR__ . '/Blocks' ); $settings->setErrorCallback( function ( array $errors ) { 	// todo log or any other actions 	echo '<pre>' . print_r( $errors, true ) . '</pre>'; } ); $blocks = new Blocks( $settings );  //// usage  $blockA_Controller = new BlockA_C(); $blockA_Controller->getModel()->load();  $blockB_Controller = new BlockB_C(); $blockB_Controller->getModel()->load();  $content = $blocks->renderBlock( $blockA_Controller ); $content .= $blocks->renderBlock( $blockB_Controller ); $css     = $blocks->getUsedResources( '.css', true );  //// html  ?> <html>  <head>      <title>Example</title>     <style>         <?= $css ?>     </style>     <style>         .block-b {             margin-top: 10px;         }     </style>  </head>  <body>  <?= $content ?>  </body>  </html> 

в результате вывод будет примерно таким

example.png

Послесловие

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

Вот и все, спасибо за внимание.

Ссылки:

репозиторий с мини фреймворком

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

репозиторий с примером использования scss и js в блоках (webpack сборщик)

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

ссылка на оригинал статьи https://habr.com/ru/post/556494/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *