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

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!

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

Источник: https://habr.com/ru/post/558422/


Интересные статьи

Интересные статьи

Всем привет. Меня зовут Дмитрий Андриянов. Два года писал на React Native, сейчас я разработчик в Surf и уже полтора года пишу на Flutter. Когда я только решил серьёзно взяться за Flu...
Для одних Ким Дотком, основатель скандально известного файлообменника «MegaUpload», преступник и интернет-пират, для других — несгибаемый борец за неприкосновенность персональных данных. 12 марта...
Вот уже несколько лет, как почти каждая статья о передовых подходах к кэшированию рекомендует пользоваться в продакшне следующими методиками: Добавление в имена файлов информации о версии со...
Как сделать стандарт за 10 дней, я рассказывал раньше. Сейчас я хотел бы рассказать о терминологии и названиях документов, их значении и разных подходах к составлению документации. Конечно, все з...
Мобильное приложение для самозанятых россиян, версия под Android Финансовый сайт Banki.ru сообщает, что самозанятые россияне столкнулись с первыми блокировками счетов. Это связано с тем, что...