Доброго времени суток уважаемые читатели хабра. С каждым годом в веб разработке появляется все больше разнообразных решений которые используют модульный подход и упрощают разработку и редактирование кода. В данной статье я предлагаю вам свой взгляд на то, какими могут быть переиспользуемые 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, отмечу важный для меня пункт, то что он дисциплинирует, т.е. заставляет отделять обработку от вывода и подготавливать переменные заранее, и в данном случае мы будем использовать его.
Теперь давайте продумаем структуру нашего мини фреймворка более детально.
Блок
Каждый блок будет состоять из:
Статических ресурсов (css/js/twig)
Класса модели (его поля мы будет предоставлять как данные для twig шаблона)
Класса контролера (он будет отвечать за наши ресурсы, их зависимости друг от друга и связывать модель с twig шаблоном)
Вспомогательные классы : Класс Settings (будет содержать путь к блокам, их пространство имен и т.д.), класс обертка для Twig пакета
Blocks класс
Связующий класс, который :
будет содержать вспомогательные классы (Settings, Twig)
предоставлять функцию рендера блока
содержать список использованных блоков, чтобы иметь возможность получить их ресурсы (css/js)
автоматически загружать все контроллеры (небольшой задел на будущее, это немного выходит за рамки текущей статьи, скажу просто что необходимо для тестов и возможности расширения)
Требования к блокам
Теперь когда мы определились со структурой пришло время зажечь оправдать слово фреймворк в названии для нашего пакета, а именно – указать требования к коду наших блоков:
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