Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
"phpunit/phpunit": "^12",
"rector/rector": "^2.0",
"symfony/event-dispatcher": "^6.0 || ^7.1",
"symfony/maker-bundle": "^1.64",
"thecodingmachine/phpstan-safe-rule": "^1.4"
},
"scripts": {
Expand Down
4 changes: 4 additions & 0 deletions config/services.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
use Storyblok\Bundle\Controller\WebhookController;
use Storyblok\Bundle\DataCollector\StoryblokCollector;
use Storyblok\Bundle\Listener\UpdateProfilerListener;
use Storyblok\Bundle\Maker\MakeStoryblokBlock;
use Storyblok\Bundle\Tiptap\DefaultEditorBuilder;
use Storyblok\Bundle\Tiptap\EditorBuilderInterface;
use Storyblok\Bundle\Twig\BlockExtension;
Expand Down Expand Up @@ -171,5 +172,8 @@

->set(ContentTypeRegistry::class)
->alias(ContentTypeRegistryInterface::class, ContentTypeRegistry::class)

->set(MakeStoryblokBlock::class)
->tag('maker.command')
;
};
194 changes: 194 additions & 0 deletions src/Maker/MakeStoryblokBlock.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
<?php

declare(strict_types=1);

namespace Storyblok\Bundle\Maker;

use Storyblok\Bundle\Block\Attribute\AsBlock;
use Storyblok\Bundle\Util\ValueObjectTrait;
use Symfony\Bundle\MakerBundle\ConsoleStyle;
use Symfony\Bundle\MakerBundle\DependencyBuilder;
use Symfony\Bundle\MakerBundle\Generator;
use Symfony\Bundle\MakerBundle\InputConfiguration;
use Symfony\Bundle\MakerBundle\Maker\AbstractMaker;
use Symfony\Bundle\MakerBundle\Str;
use Symfony\Bundle\MakerBundle\Util\ClassSource\Model\ClassData;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Question\Question;
use function PHPUnit\Framework\matches;

/**
* @author Silas Joisten <[email protected]>
*/
final class MakeStoryblokBlock extends AbstractMaker
{
private ClassData $classData;

public static function getCommandName(): string
{
return 'make:storyblok:block';
}

public static function getCommandDescription(): string
{
return 'Create a new block class';
}

public function configureCommand(Command $command, InputConfiguration $inputConfig): void
{
$command
->addArgument('name', InputArgument::OPTIONAL, \sprintf('Use the technical name you have configured your block in Storyblok with (e.g. <fg=yellow>%s</>)', Str::asSnakeCase(Str::getRandomTerm())))
->setHelp(<<<'TXT'
The <info>%command.name%</info> command generates a new block class.

<info>php %command.full_name% hero_section</info>

If the argument is missing, the command will ask for the technical name interactively.
TXT
);
}

public function interact(InputInterface $input, ConsoleStyle $io, Command $command): void
{
$this->classData = ClassData::create(
class: \sprintf('App\\Blocks\\%s', Str::asClassName($input->getArgument('name'))),
suffix: '',
useStatements: [
AsBlock::class,
ValueObjectTrait::class,
],
);

$this->classData->setIsFinal(true);
}

public function configureDependencies(DependencyBuilder $dependencies): void
{
}

public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator): void
{
$fields = $this->getPropertyNames($this->classData->getFullClassName());

$isFirstField = true;
$newFields = [];
while (true) {
$newField = $this->askForNextField($io, $fields, $isFirstField);
$isFirstField = false;

if (null === $newField) {
break;
}

if (null !== $useStatement = $newField->type->useStatement()) {

Check failure on line 85 in src/Maker/MakeStoryblokBlock.php

View workflow job for this annotation

GitHub Actions / Static Code Analysis (8.3)

Cannot call method useStatement() on Storyblok\Bundle\Maker\Type|null.
$this->classData->addUseStatement($useStatement);
}

$newFields[] = $newField;
}

$generator->generateClassFromClassData($this->classData, __DIR__.'/templates/Block.tpl.php', [
'properties' => $newFields,
]);
$generator->writeChanges();
}

/**
* @param string[] $fields
*/
private function askForNextField(ConsoleStyle $io, array $fields, bool $isFirstField): ?Property
{
$io->writeln('');

if ($isFirstField) {
$questionText = 'New property name (press <return> to stop adding fields)';
} else {
$questionText = 'Add another property? Enter the property name (or press <return> to stop adding fields)';
}

$fieldName = $io->ask($questionText, null, function ($name) use ($fields) {
// allow it to be empty
if (!$name) {
return $name;
}

if (\in_array($name, $fields)) {
throw new \InvalidArgumentException(\sprintf('The "%s" property already exists.', $name));
}

return Str::asLowerCamelCase($name);
});

if (null === $fieldName) {
return null;
}

$type = null;
$defaultType = TypeGuesser::guessType(Str::asSnakeCase($fieldName));
$availableTypes = \array_map(static fn(Type $type) => $type->value, Type::cases());

while (null === $type) {
$question = new Question('Field type (enter <comment>?</comment> to see all types)', $defaultType->value);
$question->setAutocompleterValues($availableTypes);
$type = $io->askQuestion($question);

if ('?' === $type) {
$io->listing($availableTypes);
$io->writeln('');
$type = null;
} elseif (!\in_array($type, $availableTypes)) {
$io->listing($availableTypes);
$io->error(\sprintf('Invalid type "%s".', $type));
$io->writeln('');

$type = null;
}
}

$classProperty = new Property(name: $fieldName, type: Type::from($type));

if ('string' === $type) {
$classProperty->maxLength = $io->ask('Maximum field length');
// } elseif ('decimal' === $type) {
// // 10 is the default value given in \Doctrine\DBAL\Schema\Column::$_precision
// $classProperty->precision = $io->ask('Precision (total number of digits stored: 100.00 would be 5)', '10', Validator::validatePrecision(...));
//
// // 0 is the default value given in \Doctrine\DBAL\Schema\Column::$_scale
// $classProperty->scale = $io->ask('Scale (number of decimals to store: 100.00 would be 2)', '0', Validator::validateScale(...));
} elseif ('enum' === $type) {
// ask for valid backed enum class
$fqcn = $io->ask('Enum class');
$classProperty->class = Str::getShortClassName($fqcn);
$this->classData->addUseStatement($fqcn);
} elseif ('list' === $type) {
$fqcn = $io->ask('Object class');
$classProperty->class = Str::getShortClassName($fqcn);
$this->classData->addUseStatement($fqcn);
} elseif ('blocks' === $type) {
$classProperty->min = (int) $io->ask('Min items count');
$classProperty->max = (int) $io->ask('Max items count');
}

if ($io->confirm('Is this property optional', false)) {
$classProperty->nullable = true;
}

return $classProperty;
}

private function getPropertyNames(string $class): array

Check failure on line 181 in src/Maker/MakeStoryblokBlock.php

View workflow job for this annotation

GitHub Actions / Static Code Analysis (8.3)

Method Storyblok\Bundle\Maker\MakeStoryblokBlock::getPropertyNames() return type has no value type specified in iterable type array.
{
if (!class_exists($class)) {
return [];
}

$reflectionClass = new \ReflectionClass($class);

return \array_map(
static fn (\ReflectionProperty $property) => $property->getName(),
$reflectionClass->getProperties(),
);
}
}
50 changes: 50 additions & 0 deletions src/Maker/Property.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<?php

namespace Storyblok\Bundle\Maker;

/**
* @internal
*/
final class Property
{
public function __construct(
public ?string $name = null,
public ?Type $type = null,
public bool $nullable = false,
public ?int $maxLength = null,
public ?string $class = null,
public ?int $min = null,
public ?int $max = null,
) {
}

public function toString(): string
{
return sprintf(
'public %s%s $%s;',
$this->nullable ? '?' : '',
$this->typehint(),
$this->name,
);
}

public function typehint(): ?string
{
return match ($this->type) {

Check failure on line 33 in src/Maker/Property.php

View workflow job for this annotation

GitHub Actions / Static Code Analysis (8.3)

Match expression does not handle remaining value: null
Type::Asset,
Type::DateTimeImmutable,
Type::Editable,
Type::Link,
Type::RichText,
Type::Uuid,
Type::MultiLink => $this->type->name,
Type::String => 'string',
Type::Float => 'float',
Type::Integer => 'int',
Type::Boolean => 'bool',
Type::List,
Type::Blocks => 'array',
Type::Enum => $this->class,
};
}
}
49 changes: 49 additions & 0 deletions src/Maker/Type.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php

declare(strict_types=1);

namespace Storyblok\Bundle\Maker;

use OskarStark\Enum\Trait\Comparable;
use Storyblok\Api\Domain\Type\Asset;
use Storyblok\Api\Domain\Type\Editable;
use Storyblok\Api\Domain\Type\MultiLink;
use Storyblok\Api\Domain\Type\RichText;
use Storyblok\Api\Domain\Value\Link;
use Storyblok\Api\Domain\Value\Uuid;

enum Type: string
{
use Comparable;

case String = 'string';
case RichText = 'rich_text';
case Integer = 'integer';
case Float = 'float';
case Blocks = 'blocks';
case List = 'list';
case Asset = 'asset';
case Enum = 'enum';
case DateTimeImmutable = 'datetime_immutable';
case Uuid = 'uuid';
case MultiLink = 'multi_link';
case Link = 'link';
case Boolean = 'boolean';
case Editable = 'editable';

/**
* @return class-string|null
*/
public function useStatement(): ?string
{
return match ($this) {
self::RichText => RichText::class,
self::Uuid => Uuid::class,
self::MultiLink => MultiLink::class,
self::Link => Link::class,
self::Editable => Editable::class,
self::Asset => Asset::class,
default => null,
};
}
}
67 changes: 67 additions & 0 deletions src/Maker/TypeGuesser.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
<?php

declare(strict_types=1);

namespace Storyblok\Bundle\Maker;

/**
* @internal
*/
final readonly class TypeGuesser
{
/**
* @var array<non-empty-string, Type>
*/
private const array STARTS_WITH = [
'is' => Type::Boolean,
'has' => Type::Boolean,
];

/**
* @var array<non-empty-string, Type>
*/
private const array ENDS_WITH = [
'At' => Type::DateTimeImmutable,
'Time' => Type::DateTimeImmutable,
'time' => Type::DateTimeImmutable,
'able' => Type::Boolean,
'Id' => Type::Uuid,
'id' => Type::Uuid,
];

/**
* @var array<non-empty-string, Type>
*/
private const array CONTAINS = [
'description' => Type::RichText,
'content' => Type::RichText,
'body' => Type::Blocks,
];

public static function guessType(string $propertyName): Type
{
foreach (self::STARTS_WITH as $needle => $type) {
if (\str_starts_with($propertyName, $needle)) {
return $type;
}
}

foreach (self::CONTAINS as $needle => $type) {
if (\str_contains($propertyName, $needle)) {
return $type;
}
}

foreach (self::ENDS_WITH as $needle => $type) {
if (\str_ends_with($propertyName, $needle)) {
return $type;
}
}

return Type::String;
}

private function __construct()
{
}
}
Loading
Loading