diff --git a/composer.json b/composer.json index ab72ecc..32ad24f 100644 --- a/composer.json +++ b/composer.json @@ -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": { diff --git a/config/services.php b/config/services.php index e2f4fed..56d99a2 100644 --- a/config/services.php +++ b/config/services.php @@ -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; @@ -171,5 +172,8 @@ ->set(ContentTypeRegistry::class) ->alias(ContentTypeRegistryInterface::class, ContentTypeRegistry::class) + + ->set(MakeStoryblokBlock::class) + ->tag('maker.command') ; }; diff --git a/src/Maker/MakeStoryblokBlock.php b/src/Maker/MakeStoryblokBlock.php new file mode 100644 index 0000000..94838ca --- /dev/null +++ b/src/Maker/MakeStoryblokBlock.php @@ -0,0 +1,194 @@ + + */ +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. %s)', Str::asSnakeCase(Str::getRandomTerm()))) + ->setHelp(<<<'TXT' +The %command.name% command generates a new block class. + +php %command.full_name% hero_section + +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()) { + $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 to stop adding fields)'; + } else { + $questionText = 'Add another property? Enter the property name (or press 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 ? 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 + { + if (!class_exists($class)) { + return []; + } + + $reflectionClass = new \ReflectionClass($class); + + return \array_map( + static fn (\ReflectionProperty $property) => $property->getName(), + $reflectionClass->getProperties(), + ); + } +} diff --git a/src/Maker/Property.php b/src/Maker/Property.php new file mode 100644 index 0000000..0681269 --- /dev/null +++ b/src/Maker/Property.php @@ -0,0 +1,50 @@ +nullable ? '?' : '', + $this->typehint(), + $this->name, + ); + } + + public function typehint(): ?string + { + return match ($this->type) { + 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, + }; + } +} diff --git a/src/Maker/Type.php b/src/Maker/Type.php new file mode 100644 index 0000000..7cbf9d8 --- /dev/null +++ b/src/Maker/Type.php @@ -0,0 +1,49 @@ + RichText::class, + self::Uuid => Uuid::class, + self::MultiLink => MultiLink::class, + self::Link => Link::class, + self::Editable => Editable::class, + self::Asset => Asset::class, + default => null, + }; + } +} diff --git a/src/Maker/TypeGuesser.php b/src/Maker/TypeGuesser.php new file mode 100644 index 0000000..d84df91 --- /dev/null +++ b/src/Maker/TypeGuesser.php @@ -0,0 +1,67 @@ + + */ + private const array STARTS_WITH = [ + 'is' => Type::Boolean, + 'has' => Type::Boolean, + ]; + + /** + * @var array + */ + 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 + */ + 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() + { + } +} diff --git a/src/Maker/templates/Block.tpl.php b/src/Maker/templates/Block.tpl.php new file mode 100644 index 0000000..5f5aba6 --- /dev/null +++ b/src/Maker/templates/Block.tpl.php @@ -0,0 +1,37 @@ + + +namespace getNamespace() ?>; + +getUseStatements(); ?> + +#[AsBlock] +getClassDeclaration() ?> + +{ + use ValueObjectTrait; + + +type->value, ['blocks', 'list'])): ?> + /** + * @var list<class ?? 'object' ?>> + */ + + toString() ?> + + + /** + * @param array $values + */ + public function __construct(array $values) + { + +nullable): ?> + %s = self::nullOr%s($values, \'%s\');', $property->name, \ucfirst($property->typehint()), \Symfony\Bundle\MakerBundle\Str::asSnakeCase($property->name)) ?> + + %s = self::%s($values, \'%s\');', $property->name, $property->typehint(), \Symfony\Bundle\MakerBundle\Str::asSnakeCase($property->name)) ?> + + + + + } +}