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

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

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

    Предисловие

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

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

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

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

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

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

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

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

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

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

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

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

    1. Блок

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

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

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

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

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

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

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

    • php 7.4

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

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

    Реализация

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

    Block

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

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

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

    BlocksLoader

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

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

    Renderer

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

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

    Settings

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

    Settings.php
    <?php
    
    declare(strict_types=1);
    
    namespace LightSource\FrontBlocks;
    
    class Settings
    {
    
        private array $blockFoldersInfo;
        private array $twigArgs;
        private string $twigExtension;
        private $errorCallback;
    
        public function __construct()
        {
            $this->blockFoldersInfo = [];
            $this->twigArgs         = [
                // will generate exception if a var doesn't exist instead of replace to NULL
                'strict_variables' => true,
                // disable autoescape to prevent break data
                'autoescape'       => false,
            ];
            $this->twigExtension    = '.twig';
            $this->errorCallback    = null;
        }
    
        public function addBlocksFolder(string $namespace, string $folder): void
        {
            $this->blockFoldersInfo[$namespace] = $folder;
        }
    
        public function setTwigArgs(array $twigArgs): void
        {
            $this->twigArgs = array_merge($this->twigArgs, $twigArgs);
        }
    
        public function setErrorCallback(?callable $errorCallback): void
        {
            $this->errorCallback = $errorCallback;
        }
    
        public function setTwigExtension(string $twigExtension): void
        {
            $this->twigExtension = $twigExtension;
        }
    
        public function getBlockFoldersInfo(): array
        {
            return $this->blockFoldersInfo;
        }
    
        public function getBlockFolderInfoByBlockClass(string $blockClass): ?array
        {
            foreach ($this->blockFoldersInfo as $blockNamespace => $blockFolder) {
                if (0 !== strpos($blockClass, $blockNamespace)) {
                    continue;
                }
    
                return [
                    'namespace' => $blockNamespace,
                    'folder'    => $blockFolder,
                ];
            }
    
            return null;
        }
    
        public function getTwigArgs(): array
        {
            return $this->twigArgs;
        }
    
        public function getTwigExtension(): string
        {
            return $this->twigExtension;
        }
    
        public function callErrorCallback(array $errors): void
        {
            if (! is_callable($this->errorCallback)) {
                return;
            }
    
            call_user_func_array($this->errorCallback, [$errors,]);
        }
    }
    
    SettingsTest.php
    <?php
    
    declare(strict_types=1);
    
    namespace LightSource\FrontBlocks\Tests\unit;
    
    use Codeception\Test\Unit;
    use LightSource\FrontBlocks\Settings;
    
    class SettingsTest extends Unit
    {
        public function testGetBlockFolderInfoByBlockClass()
        {
            $settings = new Settings();
            $settings->addBlocksFolder('TestNamespace', 'test-folder');
            $this->assertEquals(
                [
                    'namespace' => 'TestNamespace',
                    'folder'    => 'test-folder',
                ],
                $settings->getBlockFolderInfoByBlockClass('TestNamespace\Class')
            );
        }
    
        public function testGetBlockFolderInfoByBlockClassWhenSeveral()
        {
            $settings = new Settings();
            $settings->addBlocksFolder('FirstNamespace', 'first-namespace');
            $settings->addBlocksFolder('SecondNamespace', 'second-namespace');
            $this->assertEquals(
                [
                    'namespace' => 'FirstNamespace',
                    'folder'    => 'first-namespace',
                ],
                $settings->getBlockFolderInfoByBlockClass('FirstNamespace\Class')
            );
        }
    
        public function testGetBlockFolderInfoByBlockClassIgnoreWrong()
        {
            $settings = new Settings();
            $settings->addBlocksFolder('TestNamespace', 'test-folder');
            $this->assertEquals(
                null,
                $settings->getBlockFolderInfoByBlockClass('WrongNamespace\Class')
            );
        }
    }
    

    TwigWrapper

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

    TwigWrapper.php
    <?php
    
    declare(strict_types=1);
    
    namespace LightSource\FrontBlocks;
    
    use Exception;
    use Twig\Environment;
    use Twig\Loader\FilesystemLoader;
    use Twig\Loader\LoaderInterface;
    use Twig\TwigFilter;
    use Twig\TwigFunction;
    
    class TwigWrapper
    {
    
        private ?LoaderInterface $twigLoader;
        private ?Environment $twigEnvironment;
        private Settings $settings;
    
        public function __construct(Settings $settings, ?LoaderInterface $twigLoader = null)
        {
            $this->twigEnvironment = null;
            $this->settings        = $settings;
            $this->twigLoader      = $twigLoader;
    
            $this->init();
        }
    
        private static function GetTwigNamespace(string $namespace)
        {
            return str_replace('\\', '_', $namespace);
        }
    
        // e.g for extend a twig with adding a new filter
        public function getEnvironment(): ?Environment
        {
            return $this->twigEnvironment;
        }
    
        private function extendTwig(): void
        {
            $this->twigEnvironment->addFilter(
                new TwigFilter(
                    '_merge',
                    function ($source, $additional) {
                        return Helper::arrayMergeRecursive($source, $additional);
                    }
                )
            );
            $this->twigEnvironment->addFunction(
                new TwigFunction(
                    '_include',
                    function ($block, $args = []) {
                        $block = Helper::arrayMergeRecursive($block, $args);
    
                        return $block[Block::TEMPLATE_KEY_IS_LOADED] ?
                            $this->render(
                                $block[Block::TEMPLATE_KEY_NAMESPACE],
                                $block[Block::TEMPLATE_KEY_TEMPLATE],
                                $block
                            ) :
                            '';
                    }
                )
            );
        }
    
        private function init(): void
        {
            $blockFoldersInfo = $this->settings->getBlockFoldersInfo();
    
            try {
                // can be already init (in tests)
                if (! $this->twigLoader) {
                    $this->twigLoader = new FilesystemLoader();
                    foreach ($blockFoldersInfo as $namespace => $folder) {
                        $this->twigLoader->addPath($folder, self::GetTwigNamespace($namespace));
                    }
                }
    
                $this->twigEnvironment = new Environment($this->twigLoader, $this->settings->getTwigArgs());
            } catch (Exception $ex) {
                $this->twigEnvironment = null;
    
                $this->settings->callErrorCallback(
                    [
                        'message' => $ex->getMessage(),
                        'file'    => $ex->getFile(),
                        'line'    => $ex->getLine(),
                        'trace'   => $ex->getTraceAsString(),
                    ]
                );
    
                return;
            }
    
            $this->extendTwig();
        }
    
        public function render(string $namespace, string $template, array $args = [], bool $isPrint = false): string
        {
            $html = '';
    
            // twig isn't loaded
            if (is_null($this->twigEnvironment)) {
                return $html;
            }
    
            // can be empty, e.g. for tests
            $twigNamespace = $namespace ?
                '@' . self::GetTwigNamespace($namespace) . '/' :
                '';
    
            try {
                // will generate ean exception if a template doesn't exist OR broken
                // also if a var doesn't exist (if using a 'strict_variables' flag, see Twig_Environment->__construct)
                $html .= $this->twigEnvironment->render($twigNamespace . $template, $args);
            } catch (Exception $ex) {
                $html = '';
    
                $this->settings->callErrorCallback(
                    [
                        'message'  => $ex->getMessage(),
                        'file'     => $ex->getFile(),
                        'line'     => $ex->getLine(),
                        'trace'    => $ex->getTraceAsString(),
                        'template' => $template,
                    ]
                );
            }
    
            if ($isPrint) {
                echo $html;
            }
    
            return $html;
        }
    }
    
    TwigWrapperTest.php
    <?php
    
    declare(strict_types=1);
    
    namespace LightSource\FrontBlocks\Tests\unit;
    
    use Codeception\Test\Unit;
    use LightSource\FrontBlocks\Block;
    use LightSource\FrontBlocks\Settings;
    use LightSource\FrontBlocks\TwigWrapper;
    use Twig\Loader\ArrayLoader;
    
    class TwigWrapperTest extends Unit
    {
    
        private function renderBlock(array $blocks, string $template, array $renderArgs = []): string
        {
            $twigLoader = new ArrayLoader($blocks);
            $settings   = new Settings();
            $twig       = new TwigWrapper($settings, $twigLoader);
    
            return $twig->render('', $template, $renderArgs);
        }
    
        public function testExtendTwigIncludeFunctionWhenBlockIsLoaded()
        {
            $blocks     = [
                'form.twig'   => '{{ _include(button) }}',
                'button.twig' => 'button content',
            ];
            $template   = 'form.twig';
            $renderArgs = [
                'button' => [
                    Block::TEMPLATE_KEY_NAMESPACE => '',
                    Block::TEMPLATE_KEY_TEMPLATE  => 'button.twig',
                    Block::TEMPLATE_KEY_IS_LOADED => true,
                ],
            ];
    
            $this->assertEquals('button content', $this->renderBlock($blocks, $template, $renderArgs));
        }
    
        public function testExtendTwigIncludeFunctionWhenBlockNotLoaded()
        {
            $blocks     = [
                'form.twig'   => '{{ _include(button) }}',
                'button.twig' => 'button content',
            ];
            $template   = 'form.twig';
            $renderArgs = [
                'button' => [
                    Block::TEMPLATE_KEY_NAMESPACE => '',
                    Block::TEMPLATE_KEY_TEMPLATE  => 'button.twig',
                    Block::TEMPLATE_KEY_IS_LOADED => false,
                ],
            ];
    
            $this->assertEquals('', $this->renderBlock($blocks, $template, $renderArgs));
        }
    
        public function testExtendTwigIncludeFunctionWhenArgsPassed()
        {
            $blocks     = [
                'form.twig'   => '{{ _include(button,{classes:["test-class",],}) }}',
                'button.twig' => '{{ classes|join(" ") }}',
            ];
            $template   = 'form.twig';
            $renderArgs = [
                'button' => [
                    Block::TEMPLATE_KEY_NAMESPACE => '',
                    Block::TEMPLATE_KEY_TEMPLATE  => 'button.twig',
                    Block::TEMPLATE_KEY_IS_LOADED => true,
                    'classes'                     => ['own-class',],
                ],
            ];
    
            $this->assertEquals('own-class test-class', $this->renderBlock($blocks, $template, $renderArgs));
        }
    
        public function testExtendTwigMergeFilter()
        {
            $blocks     = [
                'button.twig' => '{{ {"array":["first",],}|_merge({"array":["second",],}).array|join(" ") }}',
            ];
            $template   = 'button.twig';
            $renderArgs = [];
    
            $this->assertEquals('first second', $this->renderBlock($blocks, $template, $renderArgs));
        }
    }
    

    Helper

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

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

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

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

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

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

    Header

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

    Button

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

    Article

    Article.php
    <?php
    
    namespace LightSource\FrontBlocksSample\Article;
    
    use LightSource\FrontBlocks\Block;
    use LightSource\FrontBlocksSample\Button\Button;
    
    class Article extends Block
    {
    
        protected string $name;
        protected Button $button;
    
        public function loadByTest()
        {
            parent::load();
            $this->name = 'I\'m Article, I contain another block';
            $this->button->loadByTest();
        }
    }
    
    Article.twig
    <div class="article">
    
        <p class="article__name">{{ name }}</p>
    
        {{ _include(button) }}
    
    </div>
    Article.css
    .article {
        color: orange;
        border: 1px solid orange;
        padding: 10px;
    }
    
    .article__name {
        margin: 0 0 10px;
        line-height: 1.5;
    }
    

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

    example.php
    <?php
    
    use LightSource\FrontBlocks\{
        Renderer,
        Settings
    };
    use LightSource\FrontBlocksSample\{
        Article\Article,
        Header\Header
    };
    
    require_once __DIR__ . '/vendors/vendor/autoload.php';
    
    //// settings
    
    ini_set('display_errors', 1);
    
    $settings = new Settings();
    $settings->addBlocksFolder('LightSource\FrontBlocksSample', __DIR__ . '/Blocks');
    $settings->setErrorCallback(
        function (array $errors) {
            // todo log or any other actions
            echo '<pre>' . print_r($errors, true) . '</pre>';
        }
    );
    $renderer = new Renderer($settings);
    
    //// usage
    
    $header = new Header();
    $header->loadByTest();
    
    $article = new Article();
    $article->loadByTest();
    
    $content = $renderer->render($header);
    $content .= $renderer->render($article);
    $css     = $renderer->getUsedResources('.css', true);
    
    //// html
    
    ?>
    <html>
    
    <head>
    
        <title>Example</title>
        <style>
            <?= $css ?>
        </style>
        <style>
            .article {
                margin-top: 10px;
            }
        </style>
    
    </head>
    
    <body>
    
    <?= $content ?>
    
    </body>
    
    </html>
    

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

    example.png

    Послесловие

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

    Понравилась статья? Не забудь проголосовать.

    Ссылки:

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

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

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

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

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

    Комментарии 2

      0
      Как и в первой части пример слишком простой, и нет понимания «А зачем делать отдельный класс для 1 html тэга?». Вот если бы пример выглядел как-то так:

      Что-то похожее на код
      $renderer = new Renderer($settings);
      
      // добавить цепочечных вызовов, чтобы можно было строить такии конструкции
      $menu = (new Menu)
          ->setTemplate('header')
          ->setItems([
              'Main' => '/',
              'About' => '/about',
              // ...
          ]);
      
      $renderer->append(new Header)
          ->append($menu)
          ->append(new LoginForm)
              ->setViewedFields('login', 'password');
      
      $article = $renderer->append(new Article);
      $article->setContent('...');
      $article->setOpenGraph([
          'author' => 'Mike',
          'created' => '2021-05-24',
      ]);
      
      $renderer->append(new Footer)
          ->append(new SubscribeForm)
              ->withCaptcha()
          ->append(clone $menu)
              ->setTemplate('footer');
          
      echo $renderer->render();

      Понятно, что в конце репозитории в которые заходишь и непонятно за что схватиться, куда смотреть и чем наслаждаться :)

      А вообще кстати вопрос (в первый раз это упустил): допустим у меня есть блок Menu и естестственно у меня есть меню в шапке и в подвале, содержимое и правила отображения (внутренняя логика) у них одинаковое, а вот шаблоны разные. Каким образом можно переиспользовать блок? Наследоваться и создавать новый класс с новым шаблоном?
        0
        Каким образом можно переиспользовать блок? Наследоваться и создавать новый класс с новым шаблоном?

        Да, абсолютно верно. Создать класс наследник и далее можно расширить стили и/или переопределить шаблон. Пример расширения стилей. Тоже касается и шаблона. Просто создать twig шаблон. Twig также поддерживает расширение, по-этому можно расширить (extend) родительский шаблон и переопределить только его часть

        Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

        Самое читаемое