Modular front-end blocks - writing your own mini framework

Good day, dear readers of Habr. Every year in web development, more and more diverse solutions appear that use a modular approach and simplify the development and editing of code. In this article, I offer you my view of what reusable front-end blocks can be (for projects with a php backend) and I suggest you go through all the steps from idea to implementation with me. Sounds interesting? Then welcome to cat.





Foreword

I will introduce myself - I am a young web developer with 5 years of experience. This past year I have been working as a freelancer and most of the current projects are related to WordPress. Despite various criticisms of CMS in general and WordPress in particular, I think the WordPress architecture itself is a pretty good solution, although of course not without certain drawbacks. And one of them, in my opinion, is templates. In recent updates, big steps have been taken to fix this, and Gutenberg as a whole is becoming a powerful tool, but unfortunately most themes continue to mess with templates, styles and scripts, which makes editing something extremely painful, and reuse of code is often impossible. It was this problem that pushed me to the idea of ​​my own mini framework (read the package, but since it will impose requirements on the structure, we will proudly call it a mini framework),which would organize the structure and allow the blocks to be reused.





The implementation will be in the form of a composer package that can be used in completely different projects, without being tied to WordPress.





The motivation to write this article was the desire to share the solution for organizing modular blocks, as well as the desire of the habr reader to write his own article, which is akin to the desire to create their own package, which sometimes arises for beginners to use ready-made composer or npm packages.





As you can conclude from the text above, this is my first article on Habré, therefore, please do not throw tomatoes, do not judge strictly.





Formulation of the problem

The concept of a block below will be essentially the same concept as a block in the BEM methodology , i.e. this will be a group of html / js / css code that will represent one entity.





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. , (css/js)





    3. ( , , )





, – :





  • 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 ).








All Articles