Как стать автором
Обновить

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

Время на прочтение37 мин
Количество просмотров7.2K

Доброго времени суток уважаемые читатели хабра. С каждым годом в веб разработке появляется все больше разнообразных решений которые используют модульный подход и упрощают разработку и редактирование кода. В данной статье я предлагаю вам свой взгляд на то, какими могут быть переиспользуемые front-end блоки (для проектов с бэкендом на php) и предлагаю пройти все шаги от идеи до реализации вместе со мной. Звучит интересно? Тогда добро пожаловать под кат.

Предисловие

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

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

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

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

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

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

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

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

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

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

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

Структура мини фреймворка

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

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

  1. Блок

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

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

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

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

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

  3. Blocks класс

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

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

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

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

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

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

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

  • php 7.4+

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

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

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

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

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

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

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

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

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

      • Таким образом по правилам выше имя ресурса с контроллером ‘Block_Theme_MainC’ будет ‘block—theme--main’

Реализация

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

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

FieldsReader

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

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

FieldsReader.php
<?php

declare(strict_types=1);

namespace LightSource\FrontBlocksFramework;

use Exception;
use ReflectionProperty;

abstract class FieldsReader
{

    private array $fieldsInfo;

    public function __construct()
    {
        $this->fieldsInfo = [];

        $this->readFieldsInfo();
        $this->autoInitFields();
    }

    final protected function getFieldsInfo(): array
    {
        return $this->fieldsInfo;
    }

    protected function getFieldType(string $fieldName): ?string
    {
        $fieldType = null;

        try {
            // used static for child support
            $property = new ReflectionProperty(static::class, $fieldName);
        } catch (Exception $ex) {
            return $fieldType;
        }

        if (! $property->isProtected()) {
            return $fieldType;
        }

        return $property->getType() ?
            $property->getType()->getName() :
            '';
    }

    private function readFieldsInfo(): void
    {
        $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, Model::class) ||
                    is_subclass_of($fieldType, Controller::class)
                ) {
                    $defaultValue = new $fieldType();
                }
            } catch (Exception $ex) {
                $defaultValue = null;
            }

            // ignore fields with a custom type (null by default)
            if (is_null($defaultValue)) {
                continue;
            }

            $this->{$fieldName} = $defaultValue;
        }
    }
}
FieldsReaderTest.php
<?php

declare(strict_types=1);

namespace LightSource\FrontBlocksFramework\Tests\unit;

use Codeception\Test\Unit;
use LightSource\FrontBlocksFramework\Controller;
use LightSource\FrontBlocksFramework\FieldsReader;
use LightSource\FrontBlocksFramework\Model;

class FieldsReaderTest extends Unit
{

    public function testReadProtectedField()
    {
        $fieldsReader = new class extends FieldsReader {

            protected $loadedField;

            public function __construct()
            {
                parent::__construct();
            }

            public function getFields()
            {
                return $this->getFieldsInfo();
            }
        };

        $this->assertEquals(
            [
                'loadedField' => '',
            ],
            $fieldsReader->getFields()
        );
    }

    public function testIgnoreReadPublicField()
    {
        $fieldsReader = new class extends FieldsReader {

            public $unloadedField;

            public function __construct()
            {
                parent::__construct();
            }

            public function getFields()
            {
                return $this->getFieldsInfo();
            }
        };

        $this->assertEquals(
            [
            ],
            $fieldsReader->getFields()
        );
    }

    public function testIgnoreReadPrivateField()
    {
        $fieldsReader = new class extends FieldsReader {

            private $unloadedField;

            public function __construct()
            {
                parent::__construct();
            }

            public function getFields()
            {
                return $this->getFieldsInfo();
            }
        };

        $this->assertEquals(
            [
            ],
            $fieldsReader->getFields()
        );
    }

    public function testReadFieldWithType()
    {
        $fieldsReader = new class extends FieldsReader {

            protected string $loadedField;

            public function __construct()
            {
                parent::__construct();
            }

            public function getFields()
            {
                return $this->getFieldsInfo();
            }
        };

        $this->assertEquals(
            [
                'loadedField' => 'string',
            ],
            $fieldsReader->getFields()
        );
    }

    public function testReadFieldWithoutType()
    {
        $fieldsReader = new class extends FieldsReader {

            protected $loadedField;

            public function __construct()
            {
                parent::__construct();
            }

            public function getFields()
            {
                return $this->getFieldsInfo();
            }
        };

        $this->assertEquals(
            [
                'loadedField' => '',
            ],
            $fieldsReader->getFields()
        );
    }

    ////

    public function testAutoInitIntField()
    {
        $fieldsReader = new class extends FieldsReader {

            protected int $int;

            public function __construct()
            {
                parent::__construct();
            }

            public function getInt()
            {
                return $this->int;
            }
        };

        $this->assertTrue(0 === $fieldsReader->getInt());
    }

    public function testAutoInitFloatField()
    {
        $fieldsReader = new class extends FieldsReader {

            protected float $float;

            public function __construct()
            {
                parent::__construct();
            }

            public function getFloat()
            {
                return $this->float;
            }
        };

        $this->assertTrue(0.0 === $fieldsReader->getFloat());
    }

    public function testAutoInitStringField()
    {
        $fieldsReader = new class extends FieldsReader {

            protected string $string;

            public function __construct()
            {
                parent::__construct();
            }

            public function getString()
            {
                return $this->string;
            }
        };

        $this->assertTrue('' === $fieldsReader->getString());
    }

    public function testAutoInitBoolField()
    {
        $fieldsReader = new class extends FieldsReader {

            protected bool $bool;

            public function __construct()
            {
                parent::__construct();
            }

            public function getBool()
            {
                return $this->bool;
            }
        };

        $this->assertTrue(false === $fieldsReader->getBool());
    }

    public function testAutoInitArrayField()
    {
        $fieldsReader = new class extends FieldsReader {

            protected array $array;

            public function __construct()
            {
                parent::__construct();
            }

            public function getArray()
            {
                return $this->array;
            }
        };

        $this->assertTrue([] === $fieldsReader->getArray());
    }

    public function testAutoInitModelField()
    {
        $testModel        = new class extends Model {
        };
        $testModelClass   = get_class($testModel);
        $fieldsReader     = new class ($testModelClass) extends FieldsReader {

            protected $model;
            private $testClass;

            public function __construct($testClass)
            {
                $this->testClass = $testClass;
                parent::__construct();
            }

            public function getFieldType(string $fieldName): ?string
            {
                return ('model' === $fieldName ?
                    $this->testClass :
                    parent::getFieldType($fieldName));
            }

            public function getModel()
            {
                return $this->model;
            }
        };
        $actualModelClass = $fieldsReader->getModel() ?
            get_class($fieldsReader->getModel()) :
            '';

        $this->assertEquals($actualModelClass, $testModelClass);
    }

    public function testAutoInitControllerField()
    {
        $testController      = new class extends Controller {
        };
        $testControllerClass = get_class($testController);
        $fieldsReader        = new class ($testControllerClass) extends FieldsReader {

            protected $controller;
            private $testClass;

            public function __construct($testControllerClass)
            {
                $this->testClass = $testControllerClass;
                parent::__construct();
            }

            public function getFieldType(string $fieldName): ?string
            {
                return ('controller' === $fieldName ?
                    $this->testClass :
                    parent::getFieldType($fieldName));
            }

            public function getController()
            {
                return $this->controller;
            }
        };
        $actualModelClass    = $fieldsReader->getController() ?
            get_class($fieldsReader->getController()) :
            '';

        $this->assertEquals($actualModelClass, $testControllerClass);
    }

    public function testIgnoreInitFieldWithoutType()
    {
        $fieldsReader = new class extends FieldsReader {

            protected $default;

            public function __construct()
            {
                parent::__construct();
            }

            public function getDefault()
            {
                return $this->default;
            }
        };

        $this->assertTrue(null === $fieldsReader->getDefault());
    }
}

Model

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

Model.php
<?php

declare(strict_types=1);

namespace LightSource\FrontBlocksFramework;

abstract class Model extends FieldsReader
{

    private bool $isLoaded;

    public function __construct()
    {
        parent::__construct();

        $this->isLoaded = false;
    }

    final public function isLoaded(): bool
    {
        return $this->isLoaded;
    }

    public function getFields(): array
    {
        $args = [];

        $fieldsInfo = $this->getFieldsInfo();

        foreach ($fieldsInfo as $fieldName => $fieldType) {
            $args[$fieldName] = $this->{$fieldName};
        }

        return $args;
    }

    final protected function load(): void
    {
        $this->isLoaded = true;
    }
}
ModelTest.php
<?php

declare(strict_types=1);

namespace LightSource\FrontBlocksFramework\Tests\unit;

use Codeception\Test\Unit;
use LightSource\FrontBlocksFramework\Model;

class ModelTest extends Unit
{

    public function testGetFields()
    {
        $model = new class extends Model {

            protected string $field1;

            public function __construct()
            {
                parent::__construct();
            }

            public function update()
            {
                $this->field1 = 'just string';
            }
        };

        $model->update();

        $this->assertEquals(
            [
                'field1' => 'just string',
            ],
            $model->getFields()
        );
    }
}

Controler

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

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

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

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

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

Controller.php
<?php

declare(strict_types=1);

namespace LightSource\FrontBlocksFramework;

use Exception;

abstract class Controller extends FieldsReader
{

    public const TEMPLATE_KEY__TEMPLATE = '_template';
    public const TEMPLATE_KEY__IS_LOADED = '_isLoaded';

    private ?Model $model;
    private array $external;

    public function __construct(?Model $model = null)
    {
        parent::__construct();

        $this->model    = $model;
        $this->external = [];

        $this->autoInitModel();
    }

    final public static function getResourceInfo(Settings $settings, string $controllerClass = ''): array
    {
        // using static for children support
        $controllerClass = ! $controllerClass ?
            static::class :
            $controllerClass;

        // e.g. $controllerClass = Example/Theme/Main/Example_Theme_Main_C
        $resourceInfo = [
            'resourceName'         => '',// e.g. example--theme--main
            'relativePath'         => '',// e.g. Example/Theme/Main
            'relativeResourcePath' => '', // e.g. Example/Theme/Main/example--theme--main
        ];

        $controllerSuffix = Settings::$controllerSuffix;

        //  e.g. Example/Theme/Main/Example_Theme_Main
        $relativeControllerNamespace = $settings->getBlocksDirNamespace() ?
            str_replace($settings->getBlocksDirNamespace() . '\\', '', $controllerClass) :
            $controllerClass;
        $relativeControllerNamespace = substr(
            $relativeControllerNamespace,
            0,
            mb_strlen($relativeControllerNamespace) - mb_strlen($controllerSuffix)
        );

        // e.g. Example_Theme_Main
        $phpBlockName = explode('\\', $relativeControllerNamespace);
        $phpBlockName = $phpBlockName[count($phpBlockName) - 1];

        // e.g. example--theme--main (from Example_Theme_Main)
        $blockNameParts    = preg_split('/(?=[A-Z])/', $phpBlockName, -1, PREG_SPLIT_NO_EMPTY);
        $blockResourceName = [];
        foreach ($blockNameParts as $blockNamePart) {
            $blockResourceName[] = strtolower($blockNamePart);
        }
        $blockResourceName = implode('-', $blockResourceName);
        $blockResourceName = str_replace('_', '-', $blockResourceName);

        // e.g. Example/Theme/Main
        $relativePath = explode('\\', $relativeControllerNamespace);
        $relativePath = array_slice($relativePath, 0, count($relativePath) - 1);
        $relativePath = implode(DIRECTORY_SEPARATOR, $relativePath);

        $resourceInfo['resourceName']         = $blockResourceName;
        $resourceInfo['relativePath']         = $relativePath;
        $resourceInfo['relativeResourcePath'] = $relativePath . DIRECTORY_SEPARATOR . $blockResourceName;

        return $resourceInfo;
    }

    // can be overridden if Controller doesn't have own twig (uses parents)
    public static function getPathToTwigTemplate(Settings $settings, string $controllerClass = ''): string
    {
        return self::getResourceInfo($settings, $controllerClass)['relativeResourcePath'] .
               $settings->getTwigExtension();
    }

    // can be overridden if Controller doesn't have own model (uses parents)
    public static function getModelClass(): string
    {
        $controllerClass = static::class;
        $modelClass      = rtrim($controllerClass, Settings::$controllerSuffix);

        return ($modelClass !== $controllerClass &&
                class_exists($modelClass, true) &&
                is_subclass_of($modelClass, Model::class) ?
            $modelClass :
            '');
    }

    public static function onLoad()
    {
    }

    final public function setModel(Model $model): void
    {
        $this->model = $model;
    }

    final protected function setExternal(string $fieldName, array $args): void
    {
        $this->external[$fieldName] = $args;
    }

    private function getControllerField(string $fieldName): ?Controller
    {
        $controller = null;
        $fieldsInfo = $this->getFieldsInfo();

        if (key_exists($fieldName, $fieldsInfo)) {
            $controller = $this->{$fieldName};

            // prevent possible recursion by a mistake (if someone will create a field with self)
            // using static for children support
            $controller = ($controller &&
                           $controller instanceof Controller &&
                           get_class($controller) !== static::class) ?
                $controller :
                null;
        }

        return $controller;
    }

    public function getTemplateArgs(Settings $settings): array
    {
        $modelFields  = $this->model ?
            $this->model->getFields() :
            [];
        $templateArgs = [];

        foreach ($modelFields as $modelFieldName => $modelFieldValue) {
            if (! $modelFieldValue instanceof Model) {
                $templateArgs[$modelFieldName] = $modelFieldValue;
                continue;
            }

            $modelFieldController = $this->getControllerField($modelFieldName);
            $modelFieldArgs       = [];
            $externalFieldArgs    = $this->external[$modelFieldName] ?? [];

            if ($modelFieldController) {
                $modelFieldController->setModel($modelFieldValue);
                $modelFieldArgs = $modelFieldController->getTemplateArgs($settings);
            }

            $templateArgs[$modelFieldName] = Helper::arrayMergeRecursive($modelFieldArgs, $externalFieldArgs);
        }

        // using static for children support
        return array_merge(
            $templateArgs,
            [
                self::TEMPLATE_KEY__TEMPLATE  => static::getPathToTwigTemplate($settings),
                self::TEMPLATE_KEY__IS_LOADED => ($this->model && $this->model->isLoaded()),
            ]
        );
    }

    public function getDependencies(string $sourceClass = ''): array
    {
        $dependencyClasses = [];
        $controllerFields  = $this->getFieldsInfo();

        foreach ($controllerFields as $fieldName => $fieldType) {
            $dependencyController = $this->getControllerField($fieldName);

            if (! $dependencyController) {
                continue;
            }

            $dependencyClass = get_class($dependencyController);

            // 1. prevent the possible permanent recursion
            // 2. add only unique elements, because several fields can have the same type
            if (
                ($sourceClass && $dependencyClass === $sourceClass) ||
                in_array($dependencyClass, $dependencyClasses, true)
            ) {
                continue;
            }

            // used static for child support
            $subDependencies = $dependencyController->getDependencies(static::class);
            // only unique elements
            $subDependencies = array_diff($subDependencies, $dependencyClasses);

            // sub dependencies are before the main dependency
            $dependencyClasses = array_merge($dependencyClasses, $subDependencies, [$dependencyClass,]);
        }

        return $dependencyClasses;
    }

    // Can be overridden for declare a target model class and provide an IDE support
    public function getModel(): ?Model
    {
        return $this->model;
    }

    private function autoInitModel()
    {
        if ($this->model) {
            return;
        }

        $modelClass = static::getModelClass();

        try {
            $this->model = $modelClass ?
                new $modelClass() :
                $this->model;
        } catch (Exception $ex) {
            $this->model = null;
        }
    }
}
ControllerTest.php
<?php

declare(strict_types=1);

namespace LightSource\FrontBlocksFramework\Tests\unit;

use Codeception\Test\Unit;
use LightSource\FrontBlocksFramework\{
    Controller,
    Model,
    Settings
};

class ControllerTest extends Unit
{

    private function getModel(array $fields, bool $isLoaded = false): Model
    {
        return new class ($fields, $isLoaded) extends Model {

            private array $fields;

            public function __construct(array $fields, bool $isLoaded)
            {
                parent::__construct();

                $this->fields = $fields;

                if ($isLoaded) {
                    $this->load();
                }
            }

            public function getFields(): array
            {
                return $this->fields;
            }
        };
    }

    private function getController(?Model $model): Controller
    {
        return new class ($model) extends Controller {

            public function __construct(?Model $model = null)
            {
                parent::__construct($model);
            }
        };
    }

    private function getTemplateArgsWithoutAdditional(array $templateArgs): array
    {
        $templateArgs = array_diff_key(
            $templateArgs,
            [
                Controller::TEMPLATE_KEY__TEMPLATE  => '',
                Controller::TEMPLATE_KEY__IS_LOADED => '',
            ]
        );
        foreach ($templateArgs as $templateKey => $templateValue) {
            if (! is_array($templateValue)) {
                continue;
            }

            $templateArgs[$templateKey] = $this->getTemplateArgsWithoutAdditional($templateValue);
        }

        return $templateArgs;
    }

    ////

    public function testGetResourceInfoWithoutCamelCaseInBlockName()
    {
        $settings = new Settings();
        $settings->setControllerSuffix('C');
        $settings->setBlocksDirNamespace('Namespace');

        $this->assertEquals(
            [
                'resourceName'         => 'block',
                'relativePath'         => 'Block',
                'relativeResourcePath' => 'Block/block',
            ],
            Controller::GetResourceInfo($settings, 'Namespace\\Block\\BlockC')
        );
    }

    public function testGetResourceInfoWithCamelCaseInBlockName()
    {
        $settings = new Settings();
        $settings->setControllerSuffix('C');
        $settings->setBlocksDirNamespace('Namespace');

        $this->assertEquals(
            [
                'resourceName'         => 'block-name',
                'relativePath'         => 'BlockName',
                'relativeResourcePath' => 'BlockName/block-name',
            ],
            Controller::GetResourceInfo($settings, 'Namespace\\BlockName\\BlockNameC')
        );
    }

    public function testGetResourceInfoWithoutCamelCaseInTheme()
    {
        $settings = new Settings();
        $settings->setControllerSuffix('C');
        $settings->setBlocksDirNamespace('Namespace');

        $this->assertEquals(
            [
                'resourceName'         => 'block--theme--main',
                'relativePath'         => 'Block/Theme/Main',
                'relativeResourcePath' => 'Block/Theme/Main/block--theme--main',
            ],
            Controller::GetResourceInfo($settings, 'Namespace\\Block\\Theme\\Main\\Block_Theme_MainC')
        );
    }

    public function testGetResourceInfoWithCamelCaseInTheme()
    {
        $settings = new Settings();
        $settings->setControllerSuffix('C');
        $settings->setBlocksDirNamespace('Namespace');

        $this->assertEquals(
            [
                'resourceName'         => 'block--theme--just-main',
                'relativePath'         => 'Block/Theme/JustMain',
                'relativeResourcePath' => 'Block/Theme/JustMain/block--theme--just-main',
            ],
            Controller::GetResourceInfo($settings, 'Namespace\\Block\\Theme\\JustMain\\Block_Theme_JustMainC')
        );
    }

    ////

    public function testGetTemplateArgsWhenModelContainsBuiltInTypes()
    {
        $settings   = new Settings();
        $model      = $this->getModel(
            [
                'stringVariable' => 'just string',
            ]
        );
        $controller = $this->getController($model);

        $this->assertEquals(
            [
                'stringVariable' => 'just string',
            ],
            $this->getTemplateArgsWithoutAdditional($controller->getTemplateArgs($settings))
        );
    }

    public function testGetTemplateArgsWhenModelContainsAnotherModel()
    {
        $settings = new Settings();

        $modelA              = $this->getModel(
            [
                'modelA' => 'just string from model a',
            ]
        );
        $modelB              = $this->getModel(
            [
                'modelA' => $modelA,
                'modelB' => 'just string from model b',
            ]
        );
        $controllerForModelA = $this->getController(null);
        $controllerForModelB = new class ($modelB, $controllerForModelA) extends Controller {

            protected $modelA;

            public function __construct(?Model $model = null, $controllerForModelA)
            {
                parent::__construct($model);

                $this->modelA = $controllerForModelA;
            }
        };

        $this->assertEquals(
            [
                'modelA' => [
                    'modelA' => 'just string from model a',
                ],
                'modelB' => 'just string from model b',
            ],
            $this->getTemplateArgsWithoutAdditional($controllerForModelB->getTemplateArgs($settings))
        );
    }

    public function testGetTemplateArgsWhenControllerContainsExternalArgs()
    {
        $settings = new Settings();

        $modelA              = $this->getModel(
            [
                'additionalField' => '',
                'modelA'          => 'just string from model a',
            ]
        );
        $modelB              = $this->getModel(
            [
                'modelA' => $modelA,
                'modelB' => 'just string from model b',
            ]
        );
        $controllerForModelA = $this->getController(null);
        $controllerForModelB = new class ($modelB, $controllerForModelA) extends Controller {

            protected $modelA;

            public function __construct(?Model $model = null, $controllerForModelA)
            {
                parent::__construct($model);

                $this->modelA = $controllerForModelA;
                $this->setExternal(
                    'modelA',
                    [
                        'additionalField' => 'additionalValue',
                    ]
                );
            }
        };

        $this->assertEquals(
            [
                'modelA' => [
                    'additionalField' => 'additionalValue',
                    'modelA'          => 'just string from model a',
                ],
                'modelB' => 'just string from model b',
            ],
            $this->getTemplateArgsWithoutAdditional($controllerForModelB->getTemplateArgs($settings))
        );
    }

    public function testGetTemplateArgsContainsAdditionalFields()
    {
        $settings   = new Settings();
        $model      = $this->getModel([]);
        $controller = $this->getController($model);

        $this->assertEquals(
            [
                Controller::TEMPLATE_KEY__TEMPLATE,
                Controller::TEMPLATE_KEY__IS_LOADED,
            ],
            array_keys($controller->getTemplateArgs($settings))
        );
    }

    public function testGetTemplateArgsWhenAdditionalIsLoadedIsFalse()
    {
        $settings   = new Settings();
        $model      = $this->getModel([]);
        $controller = $this->getController($model);

        $actual = array_intersect_key(
            $controller->getTemplateArgs($settings),
            [Controller::TEMPLATE_KEY__IS_LOADED => '',]
        );

        $this->assertEquals([Controller::TEMPLATE_KEY__IS_LOADED => false,], $actual);
    }

    public function testGetTemplateArgsWhenAdditionalIsLoadedIsTrue()
    {
        $settings   = new Settings();
        $model      = $this->getModel([], true);
        $controller = $this->getController($model);

        $actual = array_intersect_key(
            $controller->getTemplateArgs($settings),
            [Controller::TEMPLATE_KEY__IS_LOADED => '',]
        );

        $this->assertEquals([Controller::TEMPLATE_KEY__IS_LOADED => true,], $actual);
    }

    public function testGetTemplateArgsAdditionalTemplateIsRight()
    {
        $settings   = new Settings();
        $model      = $this->getModel([]);
        $controller = $this->getController($model);

        $actual = array_intersect_key(
            $controller->getTemplateArgs($settings),
            [Controller::TEMPLATE_KEY__TEMPLATE => '',]
        );

        $this->assertEquals(
            [
                Controller::TEMPLATE_KEY__TEMPLATE => $controller::GetPathToTwigTemplate($settings),
            ],
            $actual
        );
    }

    ////

    public function testGetDependencies()
    {
        $controllerA = $this->getController(null);

        $controllerB = new class (null, $controllerA) extends Controller {

            protected $controllerA;

            public function __construct(?Model $model = null, $controllerA)
            {
                parent::__construct($model);

                $this->controllerA = $controllerA;
            }
        };

        $this->assertEquals(
            [
                get_class($controllerA),
            ],
            $controllerB->getDependencies()
        );
    }

    public function testGetDependenciesWithSubDependencies()
    {
        $controllerA = new class extends Controller {

            public function getDependencies(string $sourceClass = ''): array
            {
                return [
                    'A',
                ];
            }
        };

        $controllerB = new class (null, $controllerA) extends Controller {

            protected $controllerA;

            public function __construct(?Model $model = null, $controllerA)
            {
                parent::__construct($model);

                $this->controllerA = $controllerA;
            }
        };

        $this->assertEquals(
            [
                'A',
                get_class($controllerA),
            ],
            $controllerB->getDependencies()
        );
    }

    public function testGetDependenciesWithSubDependenciesRecursively()
    {
        $controllerA = new class extends Controller {

            public function getDependencies(string $sourceClass = ''): array
            {
                return [
                    'A',
                ];
            }
        };

        $controllerB = new class (null, $controllerA) extends Controller {

            protected $controllerA;

            public function __construct(?Model $model = null, $controllerA)
            {
                parent::__construct($model);

                $this->controllerA = $controllerA;
            }
        };

        $controllerC = new class (null, $controllerB) extends Controller {

            protected $controllerB;

            public function __construct(?Model $model = null, $controllerB)
            {
                parent::__construct($model);

                $this->controllerB = $controllerB;
            }
        };

        $this->assertEquals(
            [
                'A',
                get_class($controllerA),
                get_class($controllerB),
            ],
            $controllerC->getDependencies()
        );
    }

    public function testGetDependenciesWithSubDependenciesInOrderWhenSubBeforeMainDependency()
    {
        $controllerA = new class extends Controller {

            public function getDependencies(string $sourceClass = ''): array
            {
                return [
                    'A',
                ];
            }
        };

        $controllerB = new class (null, $controllerA) extends Controller {

            protected $controllerA;

            public function __construct(?Model $model = null, $controllerA)
            {
                parent::__construct($model);

                $this->controllerA = $controllerA;
            }
        };

        $this->assertEquals(
            [
                'A',
                get_class($controllerA),
            ],
            $controllerB->getDependencies()
        );
    }

    public function testGetDependenciesWithSubDependenciesWhenBlocksAreDependentFromEachOther()
    {
        $controllerA = new class extends Controller {

            protected $controllerB;

            public function setControllerB($controllerB)
            {
                $this->controllerB = $controllerB;
            }
        };

        $controllerB = new class (null, $controllerA) extends Controller {

            protected $controllerA;

            public function __construct(?Model $model = null, $controllerA)
            {
                parent::__construct($model);

                $this->controllerA = $controllerA;
            }
        };

        $controllerA->setControllerB($controllerB);

        $this->assertEquals(
            [
                get_class($controllerA),
            ],
            $controllerB->getDependencies()
        );
    }

    public function testGetDependenciesWithoutDuplicatesWhenSeveralWithOneType()
    {
        $controllerA = $this->getController(null);

        $controllerB = new class (null, $controllerA) extends Controller {

            protected $controllerA;
            protected $controllerAA;
            protected $controllerAAA;

            public function __construct(?Model $model = null, $controllerA)
            {
                parent::__construct($model);

                $this->controllerA   = $controllerA;
                $this->controllerAA  = $controllerA;
                $this->controllerAAA = $controllerA;
            }
        };

        $this->assertEquals(
            [
                get_class($controllerA),
            ],
            $controllerB->getDependencies()
        );
    }

    ////

    public function testAutoInitModel()
    {
        $modelClass      = str_replace(['::', '\\'], '_', __METHOD__);
        $controllerClass = $modelClass . Settings::$controllerSuffix;
        eval('class ' . $modelClass . ' extends ' . Model::class . ' {}');
        eval('class ' . $controllerClass . ' extends ' . Controller::class . ' {}');
        $controller = new $controllerClass();

        $actualModelClass = $controller->getModel() ?
            get_class($controller->getModel()) :
            '';

        $this->assertEquals($modelClass, $actualModelClass);
    }

    public function testAutoInitModelWhenModelHasWrongClass()
    {
        $modelClass      = str_replace(['::', '\\'], '_', __METHOD__);
        $controllerClass = $modelClass . Settings::$controllerSuffix;
        eval('class ' . $modelClass . ' {}');
        eval('class ' . $controllerClass . ' extends ' . Controller::class . ' {}');
        $controller = new $controllerClass();

        $this->assertEquals(null, $controller->getModel());
    }
}

Settings

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

Settings.php
<?php

declare(strict_types=1);

namespace LightSource\FrontBlocksFramework;

class Settings
{

    public static string $controllerSuffix = 'C';

    private string $blocksDirPath;
    private string $blocksDirNamespace;
    private array $twigArgs;
    private string $twigExtension;
    private $errorCallback;

    public function __construct()
    {
        $this->blocksDirPath      = '';
        $this->blocksDirNamespace = '';
        $this->twigArgs           = [
            // will generate exception if a var doesn't exist instead of replace to NULL
            'strict_variables' => true,
            // disable autoescape to prevent break data
            'autoescape'       => false,
        ];
        $this->twigExtension      = '.twig';
        $this->errorCallback      = null;
    }

    public function setBlocksDirPath(string $blocksDirPath): void
    {
        $this->blocksDirPath = $blocksDirPath;
    }

    public function setBlocksDirNamespace(string $blocksDirNamespace): void
    {
        $this->blocksDirNamespace = $blocksDirNamespace;
    }

    public function setTwigArgs(array $twigArgs): void
    {
        $this->twigArgs = array_merge($this->twigArgs, $twigArgs);
    }

    public function setErrorCallback(?callable $errorCallback): void
    {
        $this->errorCallback = $errorCallback;
    }

    public function setTwigExtension(string $twigExtension): void
    {
        $this->twigExtension = $twigExtension;
    }

    public function setControllerSuffix(string $controllerSuffix): void
    {
        $this->_controllerSuffix = $controllerSuffix;
    }

    public function getBlocksDirPath(): string
    {
        return $this->blocksDirPath;
    }

    public function getBlocksDirNamespace(): string
    {
        return $this->blocksDirNamespace;
    }

    public function getTwigArgs(): array
    {
        return $this->twigArgs;
    }

    public function getTwigExtension(): string
    {
        return $this->twigExtension;
    }

    public function callErrorCallback(array $errors): void
    {
        if (! is_callable($this->errorCallback)) {
            return;
        }

        call_user_func_array($this->errorCallback, [$errors,]);
    }
}

Twig

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

Twig.php
<?php

declare(strict_types=1);

namespace LightSource\FrontBlocksFramework;

use Exception;
use Twig\Environment;
use Twig\Loader\FilesystemLoader;
use Twig\Loader\LoaderInterface;
use Twig\TwigFilter;
use Twig\TwigFunction;

class Twig
{

    private ?LoaderInterface $twigLoader;
    private ?Environment $twigEnvironment;
    private Settings $settings;

    public function __construct(Settings $settings, ?LoaderInterface $twigLoader = null)
    {
        $this->twigEnvironment = null;
        $this->settings        = $settings;
        $this->twigLoader      = $twigLoader;

        $this->init();
    }

    // e.g for extend a twig with adding a new filter
    public function getEnvironment(): ?Environment
    {
        return $this->twigEnvironment;
    }

    private function extendTwig(): void
    {
        $this->twigEnvironment->addFilter(
            new TwigFilter(
                '_merge',
                function ($source, $additional) {
                    return Helper::arrayMergeRecursive($source, $additional);
                }
            )
        );
        $this->twigEnvironment->addFunction(
            new TwigFunction(
                '_include',
                function ($block, $args = []) {
                    $block = Helper::arrayMergeRecursive($block, $args);

                    return $block[Controller::TEMPLATE_KEY__IS_LOADED] ?
                        $this->render($block[Controller::TEMPLATE_KEY__TEMPLATE], $block) :
                        '';
                }
            )
        );
    }

    private function init(): void
    {
        try {
            $this->twigLoader      = ! $this->twigLoader ?
                new FilesystemLoader($this->settings->getBlocksDirPath()) :
                $this->twigLoader;
            $this->twigEnvironment = new Environment($this->twigLoader, $this->settings->getTwigArgs());
        } catch (Exception $ex) {
            $this->twigEnvironment = null;

            $this->settings->callErrorCallback(
                [
                    'message' => $ex->getMessage(),
                    'file'    => $ex->getFile(),
                    'line'    => $ex->getLine(),
                    'trace'   => $ex->getTraceAsString(),
                ]
            );

            return;
        }

        $this->extendTwig();
    }

    public function render(string $template, array $args = [], bool $isPrint = false): string
    {
        $html = '';

        // twig isn't loaded
        if (is_null($this->twigEnvironment)) {
            return $html;
        }

        try {
            // will generate ean exception if a template doesn't exist OR broken
            // also if a var doesn't exist (if using a 'strict_variables' flag, see Twig_Environment->__construct)
            $html .= $this->twigEnvironment->render($template, $args);
        } catch (Exception $ex) {
            $html = '';

            $this->settings->callErrorCallback(
                [
                    'message'  => $ex->getMessage(),
                    'file'     => $ex->getFile(),
                    'line'     => $ex->getLine(),
                    'trace'    => $ex->getTraceAsString(),
                    'template' => $template,
                ]
            );
        }

        if ($isPrint) {
            echo $html;
        }

        return $html;
    }
}
TwigTest.php
<?php

declare(strict_types=1);

namespace LightSource\FrontBlocksFramework\Tests\unit;

use Codeception\Test\Unit;
use Exception;
use LightSource\FrontBlocksFramework\Controller;
use LightSource\FrontBlocksFramework\Settings;
use LightSource\FrontBlocksFramework\Twig;
use Twig\Loader\ArrayLoader;

class TwigTest extends Unit
{

    private function renderBlock(array $blocks, string $renderBlock, array $renderArgs = []): string
    {
        $twigLoader = new ArrayLoader($blocks);
        $settings   = new Settings();

        $twig    = new Twig($settings, $twigLoader);
        $content = '';

        try {
            $content = $twig->render($renderBlock, $renderArgs);
        } catch (Exception $ex) {
            $this->fail('Twig render exception, ' . $ex->getMessage());
        }

        return $content;
    }

    public function testExtendTwigIncludeFunctionWhenBlockIsLoaded()
    {
        $blocks      = [
            'block-a.twig' => '{{ _include(blockB) }}',
            'block-b.twig' => 'block-b content',
        ];
        $renderBlock = 'block-a.twig';
        $renderArgs  = [
            'blockB' => [
                Controller::TEMPLATE_KEY__TEMPLATE  => 'block-b.twig',
                Controller::TEMPLATE_KEY__IS_LOADED => true,
            ],
        ];

        $this->assertEquals('block-b content', $this->renderBlock($blocks, $renderBlock, $renderArgs));
    }

    public function testExtendTwigIncludeFunctionWhenBlockNotLoaded()
    {
        $blocks      = [
            'block-a.twig' => '{{ _include(blockB) }}',
            'block-b.twig' => 'block-b content',
        ];
        $renderBlock = 'block-a.twig';
        $renderArgs  = [
            'blockB' => [
                Controller::TEMPLATE_KEY__TEMPLATE  => 'block-b.twig',
                Controller::TEMPLATE_KEY__IS_LOADED => false,
            ],
        ];

        $this->assertEquals('', $this->renderBlock($blocks, $renderBlock, $renderArgs));
    }

    public function testExtendTwigIncludeFunctionWhenArgsPassed()
    {
        $blocks      = [
            'block-a.twig' => '{{ _include(blockB, {classes:["test-class",],}) }}',
            'block-b.twig' => '{{ classes|join(" ") }}',
        ];
        $renderBlock = 'block-a.twig';
        $renderArgs  = [
            'blockB' => [
                Controller::TEMPLATE_KEY__TEMPLATE  => 'block-b.twig',
                Controller::TEMPLATE_KEY__IS_LOADED => true,
                'classes'                           => ['own-class',],
            ],
        ];

        $this->assertEquals('own-class test-class', $this->renderBlock($blocks, $renderBlock, $renderArgs));
    }

    public function testExtendTwigMergeFilter()
    {
        $blocks      = [
            'block-a.twig' => '{{ {"array":["a",],}|_merge({"array":["b",],}).array|join(" ") }}',
        ];
        $renderBlock = 'block-a.twig';
        $renderArgs  = [];

        $this->assertEquals('a b', $this->renderBlock($blocks, $renderBlock, $renderArgs));
    }
}

Blocks

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

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

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

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

Blocks.php
<?php

declare(strict_types=1);

namespace LightSource\FrontBlocksFramework;

class Blocks
{

    private array $loadedControllerClasses;
    private array $usedControllerClasses;
    private Settings $settings;
    private Twig $twig;

    public function __construct(Settings $settings)
    {
        $this->loadedControllerClasses = [];
        $this->usedControllerClasses   = [];
        $this->settings                = $settings;
        $this->twig                    = new Twig($settings);
    }

    final public function getLoadedControllerClasses(): array
    {
        return $this->loadedControllerClasses;
    }

    final public function getUsedControllerClasses(): array
    {
        return $this->usedControllerClasses;
    }

    final public function getSettings(): Settings
    {
        return $this->settings;
    }

    final public function getTwig(): Twig
    {
        return $this->twig;
    }

    final public function getUsedResources(string $extension, bool $isIncludeSource = false): string
    {
        $resourcesContent = '';

        foreach ($this->usedControllerClasses as $usedControllerClass) {
            $getResourcesInfoCallback = [$usedControllerClass, 'getResourceInfo'];

            if (! is_callable($getResourcesInfoCallback)) {
                $this->settings->callErrorCallback(
                    [
                        'message' => "Controller class doesn't exist",
                        'class'   => $usedControllerClass,
                    ]
                );

                continue;
            }

            $resourceInfo = call_user_func_array(
                $getResourcesInfoCallback,
                [
                    $this->settings,
                ]
            );

            $pathToResourceFile = $this->settings->getBlocksDirPath() .
                                  DIRECTORY_SEPARATOR . $resourceInfo['relativeResourcePath'] . $extension;

            if (! is_file($pathToResourceFile)) {
                continue;
            }

            $resourcesContent .= $isIncludeSource ?
                "\n/* " . $resourceInfo['resourceName'] . " */\n" :
                '';

            $resourcesContent .= file_get_contents($pathToResourceFile);
        }

        return $resourcesContent;
    }

    private function loadController(string $phpClass, array $debugArgs): bool
    {
        $isLoaded = false;

        if (
            ! class_exists($phpClass, true) ||
            ! is_subclass_of($phpClass, Controller::class)
        ) {
            $this->settings->callErrorCallback(
                [
                    'message' => "Class doesn't exist or doesn't child",
                    'args'    => $debugArgs,
                ]
            );

            return $isLoaded;
        }

        call_user_func([$phpClass, 'onLoad']);

        return true;
    }

    private function loadControllers(string $directory, string $namespace, array $controllerFileNames): void
    {
        foreach ($controllerFileNames as $controllerFileName) {
            $phpFile   = implode(DIRECTORY_SEPARATOR, [$directory, $controllerFileName]);
            $phpClass  = implode('\\', [$namespace, str_replace('.php', '', $controllerFileName),]);
            $debugArgs = [
                'directory' => $directory,
                'namespace' => $namespace,
                'phpFile'   => $phpFile,
                'phpClass'  => $phpClass,
            ];

            if (! $this->loadController($phpClass, $debugArgs)) {
                continue;
            }

            $this->loadedControllerClasses[] = $phpClass;
        }
    }

    private function loadDirectory(string $directory, string $namespace): void
    {
        // exclude ., ..
        $fs = array_diff(scandir($directory), ['.', '..']);

        $controllerFilePreg = '/' . Settings::$controllerSuffix . '.php$/';

        $controllerFileNames = Helper::arrayFilter(
            $fs,
            function ($f) use ($controllerFilePreg) {
                return (1 === preg_match($controllerFilePreg, $f));
            },
            false
        );
        $subDirectoryNames   = Helper::arrayFilter(
            $fs,
            function ($f) {
                return false === strpos($f, '.');
            },
            false
        );

        foreach ($subDirectoryNames as $subDirectoryName) {
            $subDirectory = implode(DIRECTORY_SEPARATOR, [$directory, $subDirectoryName]);
            $subNamespace = implode('\\', [$namespace, $subDirectoryName]);

            $this->loadDirectory($subDirectory, $subNamespace);
        }

        $this->loadControllers($directory, $namespace, $controllerFileNames);
    }

    final public function loadAll(): void
    {
        $directory = $this->settings->getBlocksDirPath();
        $namespace = $this->settings->getBlocksDirNamespace();

        $this->loadDirectory($directory, $namespace);
    }

    final public function renderBlock(Controller $controller, array $args = [], bool $isPrint = false): string
    {
        $dependencies                = array_merge($controller->getDependencies(), [get_class($controller),]);
        $newDependencies             = array_diff($dependencies, $this->usedControllerClasses);
        $this->usedControllerClasses = array_merge($this->usedControllerClasses, $newDependencies);

        $templateArgs = $controller->getTemplateArgs($this->settings);
        $templateArgs = Helper::arrayMergeRecursive($templateArgs, $args);

        return $this->twig->render($templateArgs[Controller::TEMPLATE_KEY__TEMPLATE], $templateArgs, $isPrint);
    }
}
BlocksTest.php
<?php

declare(strict_types=1);

namespace LightSource\FrontBlocksFramework\Tests\unit;

use Codeception\Test\Unit;
use Exception;
use LightSource\FrontBlocksFramework\Blocks;
use LightSource\FrontBlocksFramework\Controller;
use LightSource\FrontBlocksFramework\Model;
use LightSource\FrontBlocksFramework\Settings;
use LightSource\FrontBlocksFramework\Twig;
use org\bovigo\vfs\vfsStream;
use org\bovigo\vfs\vfsStreamDirectory;

class BlocksTest extends Unit
{

    private function getBlocks(
        string $namespace,
        vfsStreamDirectory $rootDirectory,
        array $structure,
        array $usedControllerClasses = []
    ): ?Blocks {
        vfsStream::create($structure, $rootDirectory);

        $settings = new Settings();
        $settings->setBlocksDirNamespace($namespace);
        $settings->setBlocksDirPath($rootDirectory->url());

        $twig = $this->make(
            Twig::class,
            [
                'render' => function (string $template, array $args = [], bool $isPrint = false): string {
                    return '';
                },
            ]
        );

        try {
            $blocks = $this->make(
                Blocks::class,
                [
                    'loadedControllerClasses' => [],
                    'usedControllerClasses'   => $usedControllerClasses,
                    'twig'                    => $twig,
                    'settings'                => $settings,
                ]
            );
        } catch (Exception $ex) {
            $this->fail("Can't make Blocks stub, " . $ex->getMessage());
        }

        $blocks->loadAll();

        return $blocks;
    }

    // get a unique namespace depending on a test method to prevent affect other tests
    private function getUniqueControllerNamespaceWithAutoloader(
        string $methodConstant,
        vfsStreamDirectory $rootDirectory
    ): string {
        $namespace = str_replace('::', '_', $methodConstant);

        spl_autoload_register(
            function ($class) use ($rootDirectory, $namespace) {
                $targetNamespace = $namespace . '\\';
                if (0 !== strpos($class, $targetNamespace)) {
                    return;
                }

                $relativePathToFile = str_replace($targetNamespace, '', $class);
                $relativePathToFile = str_replace('\\', '/', $relativePathToFile);

                $absPathToFile = $rootDirectory->url() . DIRECTORY_SEPARATOR . $relativePathToFile . '.php';

                include_once $absPathToFile;
            }
        );

        return $namespace;
    }

    // get a unique directory name depending on a test method to prevent affect other tests
    private function getUniqueDirectory(string $methodConstant): vfsStreamDirectory
    {
        $dirName = str_replace([':', '\\'], '_', $methodConstant);

        return vfsStream::setup($dirName);
    }

    private function getControllerClassFile(string $namespace, string $class): string
    {
        $vendorControllerClass = '\LightSource\FrontBlocksFramework\Controller';

        return '<?php namespace ' . $namespace . '; class ' . $class . ' extends ' . $vendorControllerClass . ' {}';
    }

    private function getController(array $dependencies = [])
    {
        return new class (null, $dependencies) extends Controller {

            private array $dependencies;

            public function __construct(?Model $model = null, array $dependencies)
            {
                parent::__construct($model);
                $this->dependencies = $dependencies;
            }

            public function getDependencies(string $sourceClass = ''): array
            {
                return $this->dependencies;
            }

            public function getTemplateArgs(Settings $settings): array
            {
                return [
                    Controller::TEMPLATE_KEY__TEMPLATE => '',
                ];
            }
        };
    }

    ////

    public function testLoadAllControllersWithPrefix()
    {
        $rootDirectory = $this->getUniqueDirectory(__METHOD__);
        $namespace     = $this->getUniqueControllerNamespaceWithAutoloader(__METHOD__, $rootDirectory);
        $blocks        = $this->getBlocks(
            $namespace,
            $rootDirectory,
            [
                'Block' => [
                    'BlockC.php' => $this->getControllerClassFile("{$namespace}\Block", 'BlockC'),
                ],
            ]
        );

        $this->assertEquals(
            [
                "{$namespace}\Block\BlockC",
            ],
            $blocks->getLoadedControllerClasses()
        );
    }

    public function testLoadAllIgnoreControllersWithoutPrefix()
    {
        $rootDirectory = $this->getUniqueDirectory(__METHOD__);
        $namespace     = $this->getUniqueControllerNamespaceWithAutoloader(__METHOD__, $rootDirectory);
        $blocks        = $this->getBlocks(
            $namespace,
            $rootDirectory,
            [
                'Block' => [
                    'Block.php' => $this->getControllerClassFile("{$namespace}\Block", 'Block'),
                ],
            ]
        );

        $this->assertEquals([], $blocks->getLoadedControllerClasses());
    }

    public function testLoadAllIgnoreWrongControllers()
    {
        $rootDirectory = $this->getUniqueDirectory(__METHOD__);
        $namespace     = $this->getUniqueControllerNamespaceWithAutoloader(__METHOD__, $rootDirectory);
        $blocks        = $this->getBlocks(
            $namespace,
            $rootDirectory,
            [
                'Block' => [
                    'BlockC.php' => $this->getControllerClassFile("{$namespace}\Block", 'WrongBlockC'),
                ],
            ]
        );

        $this->assertEquals([], $blocks->getLoadedControllerClasses());
    }

    ////

    public function testRenderBlockAddsControllerToUsedList()
    {
        $rootDirectory = $this->getUniqueDirectory(__METHOD__);
        $namespace     = $this->getUniqueControllerNamespaceWithAutoloader(__METHOD__, $rootDirectory);
        $blocks        = $this->getBlocks($namespace, $rootDirectory, []);
        $controller    = $this->getController();

        $blocks->renderBlock($controller);

        $this->assertEquals(
            [
                get_class($controller),
            ],
            $blocks->getUsedControllerClasses()
        );
    }

    public function testRenderBlockAddsControllerDependenciesToUsedList()
    {
        $rootDirectory = $this->getUniqueDirectory(__METHOD__);
        $namespace     = $this->getUniqueControllerNamespaceWithAutoloader(__METHOD__, $rootDirectory);
        $blocks        = $this->getBlocks($namespace, $rootDirectory, []);
        $controller    = $this->getController(['A',]);

        $blocks->renderBlock($controller);

        $this->assertEquals(
            [
                'A',
                get_class($controller),
            ],
            $blocks->getUsedControllerClasses()
        );
    }

    public function testRenderBlockAddsDependenciesBeforeControllerToUsedList()
    {
        $rootDirectory = $this->getUniqueDirectory(__METHOD__);
        $namespace     = $this->getUniqueControllerNamespaceWithAutoloader(__METHOD__, $rootDirectory);
        $blocks        = $this->getBlocks($namespace, $rootDirectory, []);
        $controller    = $this->getController(['A',]);

        $blocks->renderBlock($controller);

        $this->assertEquals(
            [
                'A',
                get_class($controller),
            ],
            $blocks->getUsedControllerClasses()
        );
    }

    public function testRenderBlockIgnoreDuplicateControllerWhenAddsToUsedList()
    {
        $rootDirectory = $this->getUniqueDirectory(__METHOD__);
        $namespace     = $this->getUniqueControllerNamespaceWithAutoloader(__METHOD__, $rootDirectory);
        $blocks        = $this->getBlocks($namespace, $rootDirectory, []);
        $controllerA   = $this->getController();

        $blocks->renderBlock($controllerA);
        $blocks->renderBlock($controllerA);

        $this->assertEquals(
            [
                get_class($controllerA),
            ],
            $blocks->getUsedControllerClasses()
        );
    }

    public function testRenderBlockIgnoreDuplicateControllerDependenciesWhenAddsToUsedList()
    {
        $rootDirectory = $this->getUniqueDirectory(__METHOD__);
        $namespace     = $this->getUniqueControllerNamespaceWithAutoloader(__METHOD__, $rootDirectory);
        $blocks        = $this->getBlocks($namespace, $rootDirectory, []);
        $controllerA   = $this->getController(['A',]);
        $controllerB   = $this->getController(['A',]);

        $blocks->renderBlock($controllerA);
        $blocks->renderBlock($controllerB);

        $this->assertEquals(
            [
                'A',
                get_class($controllerA),// $controllerB has the same class
            ],
            $blocks->getUsedControllerClasses()
        );
    }

    ////

    public function testGetUsedResourcesWhenBlockWithResources()
    {
        $rootDirectory = $this->getUniqueDirectory(__METHOD__);
        $namespace     = $this->getUniqueControllerNamespaceWithAutoloader(__METHOD__, $rootDirectory);
        $blocks        = $this->getBlocks(
            $namespace,
            $rootDirectory,
            [
                'Block' => [
                    'BlockC.php' => $this->getControllerClassFile("{$namespace}\Block", 'BlockC'),
                    'block.css'   => 'just css code',
                ],
            ],
            [
                "{$namespace}\Block\BlockC",
            ]
        );

        $this->assertEquals(
            'just css code',
            $blocks->getUsedResources('.css', false)
        );
    }

    public function testGetUsedResourcesWhenBlockWithoutResources()
    {
        $rootDirectory = $this->getUniqueDirectory(__METHOD__);
        $namespace     = $this->getUniqueControllerNamespaceWithAutoloader(__METHOD__, $rootDirectory);
        $blocks        = $this->getBlocks(
            $namespace,
            $rootDirectory,
            [
                'Block' => [
                    'BlockC.php' => $this->getControllerClassFile("{$namespace}\Block", 'BlockC'),
                ],
            ],
            [
                "{$namespace}\Block\BlockC",
            ]
        );

        $this->assertEquals(
            '',
            $blocks->getUsedResources('.css', false)
        );
    }

    public function testGetUsedResourcesWhenSeveralBlocks()
    {
        $rootDirectory = $this->getUniqueDirectory(__METHOD__);
        $namespace     = $this->getUniqueControllerNamespaceWithAutoloader(__METHOD__, $rootDirectory);
        $blocks        = $this->getBlocks(
            $namespace,
            $rootDirectory,
            [
                'BlockA' => [
                    'BlockAC.php' => $this->getControllerClassFile("{$namespace}\BlockA", 'BlockAC'),
                    'block-a.css'  => 'css code for a',
                ],
                'BlockB' => [
                    'BlockBC.php' => $this->getControllerClassFile("{$namespace}\BlockB", 'BlockBC'),
                    'block-b.css'  => 'css code for b',
                ],
            ],
            [
                "{$namespace}\BlockA\BlockAC",
                "{$namespace}\BlockB\BlockBC",
            ]
        );

        $this->assertEquals(
            'css code for acss code for b',
            $blocks->getUsedResources('.css', false)
        );
    }

    public function testGetUsedResourcesWithIncludedSource()
    {
        $rootDirectory = $this->getUniqueDirectory(__METHOD__);
        $namespace     = $this->getUniqueControllerNamespaceWithAutoloader(__METHOD__, $rootDirectory);
        $blocks        = $this->getBlocks(
            $namespace,
            $rootDirectory,
            [
                'SimpleBlock' => [
                    'SimpleBlockC.php' => $this->getControllerClassFile("{$namespace}\SimpleBlock", 'SimpleBlockC'),
                    'simple-block.css'  => 'css code',
                ],
            ],
            [
                "{$namespace}\SimpleBlock\SimpleBlockC",
            ]
        );

        $this->assertEquals(
            "\n/* simple-block */\ncss code",
            $blocks->getUsedResources('.css', true)
        );
    }
}

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

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

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

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

Header

Header.php
<?php

namespace LightSource\FrontBlocksExample\Header;

use LightSource\FrontBlocksFramework\Model;

class Header extends Model
{

    protected string $name;

    public function loadByTest()
    {
        parent::load();
        $this->name = 'I\'m Header';
    }

}
HeaderC.php
<?php

namespace LightSource\FrontBlocksExample\Header;

use LightSource\FrontBlocksFramework\Controller;

class HeaderC extends Controller
{

    public function getModel(): ?Header
    {
        /** @noinspection PhpIncompatibleReturnTypeInspection */
        return parent::getModel();
    }

}
header.twig
<div class="header">
    {{ name }}
</div>
header.css
.header {
    color: green;
    border:1px solid green;
    padding: 10px;
}

Article

Article.php
<?php

namespace LightSource\FrontBlocksExample\Article;

use LightSource\FrontBlocksExample\Button\Button;
use LightSource\FrontBlocksFramework\Model;

class Article extends Model
{

    protected string $name;
    protected Button $button;

    public function loadByTest()
    {
        parent::load();
        $this->name = 'I\'m Article, I contain another block';
        $this->button->loadByTest();
    }

}
ArticleC.php
<?php

namespace LightSource\FrontBlocksExample\Article;

use LightSource\FrontBlocksExample\Button\ButtonC;
use LightSource\FrontBlocksFramework\Controller;

class ArticleC extends Controller
{

    protected ButtonC $button;

    public function getModel(): ?Article
    {
        /** @noinspection PhpIncompatibleReturnTypeInspection */
        return parent::getModel();
    }

}
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;
}

Button

Button.php
<?php

namespace LightSource\FrontBlocksExample\Button;

use LightSource\FrontBlocksFramework\Model;

class Button extends Model {

	protected string $name;

	public function loadByTest() {

		parent::load();
		$this->name = 'I\'m Button';

	}

}
ButtonC.php
<?php

namespace LightSource\FrontBlocksExample\Button;

use LightSource\FrontBlocksFramework\Controller;

class ButtonC extends Controller {

	public function getModel(): ?Button {
		/** @noinspection PhpIncompatibleReturnTypeInspection */
		return parent::getModel();
	}

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

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

example.php
<?php

use LightSource\FrontBlocksExample\{
    Article\ArticleC,
    Header\HeaderC,
};
use LightSource\FrontBlocksFramework\{
    Blocks,
    Settings
};

require_once __DIR__ . '/vendors/vendor/autoload.php';

//// settings

ini_set('display_errors', 1);

$settings = new Settings();
$settings->setBlocksDirNamespace('LightSource\FrontBlocksExample');
$settings->setBlocksDirPath(__DIR__ . '/Blocks');
$settings->setErrorCallback(
    function (array $errors) {
        // todo log or any other actions
        echo '<pre>' . print_r($errors, true) . '</pre>';
    }
);
$blocks = new Blocks($settings);

//// usage

$headerController = new HeaderC();
$headerController->getModel()->loadByTest();

$articleController = new ArticleC();
$articleController->getModel()->loadByTest();

$content = $blocks->renderBlock($headerController);
$content .= $blocks->renderBlock($articleController);
$css     = $blocks->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 сборщик)

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

Теги:
Хабы:
Всего голосов 5: ↑3 и ↓2+1
Комментарии20

Публикации

Истории

Работа

React разработчик
51 вакансия
PHP программист
102 вакансии

Ближайшие события

15 – 16 ноября
IT-конференция Merge Skolkovo
Москва
22 – 24 ноября
Хакатон «AgroCode Hack Genetics'24»
Онлайн
28 ноября
Конференция «TechRec: ITHR CAMPUS»
МоскваОнлайн
25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань