From b0d882067297ff35f3697c0b9bea71848769ebaf Mon Sep 17 00:00:00 2001 From: "lina.wolf" Date: Tue, 10 Mar 2026 20:55:34 +0100 Subject: [PATCH] [FEATURE] make:viewhelper Create a ViewHelper inheriting from AbstractViewHelper or AbstractTagBasedViewHelper References: https://github.com/FriendsOfTYPO3/kickstarter/issues/198 Releases: main, 13.4 --- .../Question/ChooseExtensionKeyQuestion.php | 1 + Classes/Command/ViewHelperCommand.php | 247 +++++++++++++++ .../Creator/ViewHelper/ViewHelperCreator.php | 280 ++++++++++++++++++ .../ViewHelper/ViewHelperCreatorInterface.php | 21 ++ Classes/Information/ViewHelperInformation.php | 84 ++++++ .../Creator/ViewHelperCreatorService.php | 28 ++ Configuration/Services.yaml | 12 + .../Classes/ViewHelper/ExampleViewHelper.php | 23 ++ .../Classes/ViewHelper/ExampleViewHelper.php | 42 +++ .../ViewHelperCreatorServiceTest.php | 102 +++++++ 10 files changed, 840 insertions(+) create mode 100644 Classes/Command/ViewHelperCommand.php create mode 100644 Classes/Creator/ViewHelper/ViewHelperCreator.php create mode 100644 Classes/Creator/ViewHelper/ViewHelperCreatorInterface.php create mode 100644 Classes/Information/ViewHelperInformation.php create mode 100644 Classes/Service/Creator/ViewHelperCreatorService.php create mode 100644 Tests/Functional/Integration/Fixtures/make_viewhelper/Classes/ViewHelper/ExampleViewHelper.php create mode 100644 Tests/Functional/Integration/Fixtures/make_viewhelper_arguments/Classes/ViewHelper/ExampleViewHelper.php create mode 100644 Tests/Functional/Integration/ViewHelperCreatorServiceTest.php diff --git a/Classes/Command/Input/Question/ChooseExtensionKeyQuestion.php b/Classes/Command/Input/Question/ChooseExtensionKeyQuestion.php index bae95cc..ce35870 100644 --- a/Classes/Command/Input/Question/ChooseExtensionKeyQuestion.php +++ b/Classes/Command/Input/Question/ChooseExtensionKeyQuestion.php @@ -36,6 +36,7 @@ #[AutoconfigureTag('ext-kickstarter.command.question.type-converter')] #[AutoconfigureTag('ext-kickstarter.command.question.upgrade-wizard')] #[AutoconfigureTag('ext-kickstarter.command.question.validator')] +#[AutoconfigureTag('ext-kickstarter.command.question.view-helper')] readonly class ChooseExtensionKeyQuestion extends AbstractQuestion { public const ARGUMENT_NAME = 'choose_extension'; diff --git a/Classes/Command/ViewHelperCommand.php b/Classes/Command/ViewHelperCommand.php new file mode 100644 index 0000000..83787db --- /dev/null +++ b/Classes/Command/ViewHelperCommand.php @@ -0,0 +1,247 @@ +addArgument( + 'extension_key', + InputArgument::OPTIONAL, + 'Provide the extension key you want to extend', + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $commandContext = new CommandContext($input, $output); + $io = $commandContext->getIo(); + $io->title('Welcome to the TYPO3 Extension Builder'); + + $io->text([ + 'We are here to assist you in creating a new Fluid ViewHelper. ', + 'https://docs.typo3.org/permalink/t3coreapi:fluid-custom-viewhelper on how implement its functionality.', + ]); + + $viewHelperInformation = $this->askForViewHelperInformation($commandContext); + $this->viewHelperCreatorService->create($viewHelperInformation); + $this->printCreatorInformation($viewHelperInformation->getCreatorInformation(), $commandContext); + + return Command::SUCCESS; + } + + private function askForViewHelperInformation(CommandContext $commandContext): ViewHelperInformation + { + $io = $commandContext->getIo(); + $extensionInformation = $this->getExtensionInformation( + (string)$this->questionCollection->askQuestion( + ChooseExtensionKeyQuestion::ARGUMENT_NAME, + $commandContext, + ), + $commandContext + ); + + $name = $this->askForViewHelperName($io); + + return new ViewHelperInformation( + extensionInformation: $extensionInformation, + name: $name, + arguments: $this->askForArguments($commandContext->getIo()), + ); + } + + private function askForArguments(SymfonyStyle $io): array + { + $arguments = []; + + $typeMap = [ + 'string' => 'string', + 'int' => 'int', + 'bool' => 'bool', + 'float' => 'float', + 'array' => 'array', + 'mixed' => 'mixed', + 'DateTimeInterface' => \DateTimeInterface::class, + 'FileReference (TYPO3)' => FileReference::class, + 'UploadedFile (PSR-7)' => UploadedFileInterface::class, + 'ObjectStorage (Extbase)' => ObjectStorage::class, + 'Custom class' => 'custom', + ]; + + while (true) { + while (true) { + $name = (string)$io->ask('Argument name (leave empty to finish)'); + + if ($name === '') { + break 2; // exit BOTH loops correctly + } + + // already valid (strict Fluid-style) + if (preg_match('/^[a-z][a-zA-Z0-9]*$/', $name)) { + break; + } + + // try to suggest correction + $suggestion = $this->toLowerCamelCase($name); + + if ($suggestion === '' || !preg_match('/^[a-z][a-zA-Z0-9]*$/', $suggestion)) { + $io->error('Invalid argument name. Use lowerCamelCase (e.g. "emailAddress").'); + continue; + } + + if ($io->confirm(sprintf('Invalid argument name. Use "%s" instead?', $suggestion), true)) { + $name = $suggestion; + break; + } + + // otherwise: loop again + } + + // prevent duplicates + if (in_array($name, array_column($arguments, 0), true)) { + $io->error('Argument already exists.'); + continue; + } + + // type selection + $typeLabel = $io->choice('Type', array_keys($typeMap), 'string'); + $type = $typeMap[$typeLabel]; + + if ($type === 'custom') { + $type = $this->askForFqn($io); + } + + $description = (string)$io->ask('Description'); + $required = $io->confirm('Required?', true); + + $argument = [$name, $type, $description, $required]; + + // default only if NOT required + if (!$required) { + $defaultInput = $io->ask('Default value (leave empty for none)'); + if ($defaultInput !== null && $defaultInput !== '') { + $argument[] = $this->normalizeDefaultValue($defaultInput, $type); + } + } + + $arguments[] = $argument; + } + + return $arguments; + } + + private function askForFqn(SymfonyStyle $io): string + { + do { + $fqn = (string)$io->ask('Enter fully qualified class name (e.g. \\Vendor\\Package\\Model\\Foo)'); + + $isValid = preg_match('/^\\\\?[A-Za-z_][A-Za-z0-9_\\\\]*$/', $fqn); + + if (!$isValid) { + $io->error('Invalid class name. Must be a valid FQN.'); + } + } while (!$isValid); + + return ltrim($fqn, '\\'); // normalize + } + + private function toLowerCamelCase(string $input): string + { + $clean = preg_replace('/[^a-zA-Z0-9]+/', ' ', $input); + + $words = explode(' ', trim($clean)); + $words = array_filter($words); + + if ($words === []) { + return ''; + } + + $first = strtolower(array_shift($words)); + $rest = array_map(fn($w): string => ucfirst(strtolower($w)), $words); + + return $first . implode('', $rest); + } + + private function normalizeDefaultValue(string $value, string $type): mixed + { + return match ($type) { + 'integer', 'int' => (int)$value, + 'float' => (float)$value, + 'boolean', 'bool' => filter_var($value, FILTER_VALIDATE_BOOLEAN), + default => $value, + }; + } + + private function askForViewHelperName(SymfonyStyle $io): string + { + $defaultName = null; + do { + $name = (string)$io->ask( + 'Please provide the name of your ViewHelper', + $defaultName, + ); + + if (preg_match('/^\d/', $name)) { + $io->error('ViewHelper name should not start with a number.'); + $defaultName = $this->tryToCorrectClassName($name, ''); + $validValidatorName = false; + } elseif (preg_match('/[^a-zA-Z0-9]/', $name)) { + $io->error('ViewHelper name contains invalid chars. Please provide just letters and numbers.'); + $defaultName = $this->tryToCorrectClassName($name, ''); + $validValidatorName = false; + } elseif (preg_match('/^[a-z0-9]+$/', $name)) { + $io->error('ViewHelper must be written in UpperCamelCase like BlogExampleValidator.'); + $defaultName = $this->tryToCorrectClassName($name, ''); + $validValidatorName = false; + } else { + $validValidatorName = true; + } + } while (!$validValidatorName); + + return $name; + } +} diff --git a/Classes/Creator/ViewHelper/ViewHelperCreator.php b/Classes/Creator/ViewHelper/ViewHelperCreator.php new file mode 100644 index 0000000..8cd9064 --- /dev/null +++ b/Classes/Creator/ViewHelper/ViewHelperCreator.php @@ -0,0 +1,280 @@ +builderFactory = new BuilderFactory(); + } + + public function create(ViewHelperInformation $viewHelperInformation): void + { + GeneralUtility::mkdir_deep($viewHelperInformation->getPath()); + + $filePath = $viewHelperInformation->getPath() . $viewHelperInformation->getFilename(); + + if (is_file($filePath)) { + $viewHelperInformation->getCreatorInformation()->fileExists( + $filePath, + sprintf( + 'ViewHelpers can only be created, not modified. The file %s already exists and cannot be overridden. ', + $filePath + ) + ); + return; + } + $fileStructure = $this->buildFileStructure($filePath); + $this->addClassNodes($fileStructure, $viewHelperInformation); + $this->fileManager->createFile($filePath, $fileStructure->getFileContents(), $viewHelperInformation->getCreatorInformation()); + } + + private function addClassNodes(FileStructure $fileStructure, ViewHelperInformation $viewHelperInformation): void + { + $fileStructure->addDeclareStructure( + new DeclareStructure($this->nodeFactory->createDeclareStrictTypes()) + ); + if ($viewHelperInformation->isTagBased()) { + $this->createTagBasedViewHelper($fileStructure, $viewHelperInformation); + } else { + $this->createPlainViewHelper($fileStructure, $viewHelperInformation); + } + } + + private function createPlainViewHelper(FileStructure $fileStructure, ViewHelperInformation $viewHelperInformation): void + { + $fileStructure->addUseStructure( + new UseStructure($this->nodeFactory->createUseImport(AbstractViewHelper::class)) + ); + $fileStructure->addNamespaceStructure( + new NamespaceStructure($this->nodeFactory->createNamespace( + $viewHelperInformation->getNamespace(), + $viewHelperInformation->getExtensionInformation(), + )) + ); + $fileStructure->addClassStructure( + new ClassStructure( + $this->builderFactory + ->class($viewHelperInformation->getClassname()) + ->makeFinal() + ->extend('AbstractViewHelper') + ->getNode(), + ) + ); + + $this->addInitializeArgumentsMethod($viewHelperInformation, $fileStructure); + $methodBuilder = $this->builderFactory + ->method('render') + ->makePublic() + ->setReturnType('string'); + + foreach ($this->buildArgumentAssignments($viewHelperInformation->getArguments()) as $stmt) { + $methodBuilder->addStmt($stmt); + } + + $methodBuilder->addStmt( + $this->buildRenderReturn( + $viewHelperInformation->getArguments(), + $viewHelperInformation->getClassname() + ) + ); + $fileStructure->addMethodStructure( + new MethodStructure($methodBuilder->getNode()) + ); + } + + private function buildRegisterArgumentStatements(array $arguments): array + { + $statements = []; + + foreach ($arguments as $argument) { + [$name, $type, $description, $required] = $argument; + $defaultValue = $argument[4] ?? null; + + $args = [ + $this->builderFactory->val($name), + $this->builderFactory->val($type), + $this->builderFactory->val($description), + $this->builderFactory->val($required), + ]; + + if (array_key_exists(4, $argument)) { + $args[] = $this->builderFactory->val($defaultValue); + } + + $statements[] = new Expression( + new MethodCall( + new Variable('this'), + 'registerArgument', + array_map(fn(Expr $arg): Arg => new Arg($arg), $args) + ) + ); + } + + return $statements; + } + + private function createTagBasedViewHelper(FileStructure $fileStructure, ViewHelperInformation $viewHelperInformation): void + { + + $fileStructure->addUseStructure( + new UseStructure($this->nodeFactory->createUseImport(AbstractTagBasedViewHelper::class)) + ); + $fileStructure->addNamespaceStructure( + new NamespaceStructure($this->nodeFactory->createNamespace( + $viewHelperInformation->getNamespace(), + $viewHelperInformation->getExtensionInformation(), + )) + ); + $fileStructure->addClassStructure( + new ClassStructure( + $this->builderFactory + ->class($viewHelperInformation->getClassname()) + ->makeFinal() + ->extend('AbstractTagBasedViewHelper') + ->getNode(), + ) + ); + $methodBuilder = $this->builderFactory + ->method('initializeArguments') + ->makePublic() + ->setReturnType('void'); + + foreach ($this->buildRegisterArgumentStatements($viewHelperInformation->getArguments()) as $stmt) { + $methodBuilder = $methodBuilder->addStmt($stmt); + } + + $fileStructure->addMethodStructure( + new MethodStructure($methodBuilder->getNode()) + ); + $fileStructure->addMethodStructure( + new MethodStructure( + $this->builderFactory + ->method('render') + ->makePublic() + ->setReturnType('string') + ->addStmt(new Return_($this->builderFactory->val('ViewHelper ' . $viewHelperInformation->getClassname() . ' content. '))) + ->getNode() + ) + ); + } + + private function buildArgumentAssignments(array $arguments): array + { + $statements = []; + + foreach ($arguments as $argument) { + $name = $argument[0]; + + $statements[] = new Expression( + new Assign( + new Variable($name), + new ArrayDimFetch( + new PropertyFetch( + new Variable('this'), + 'arguments' + ), + $this->builderFactory->val($name) + ) + ) + ); + } + + return $statements; + } + + private function buildRenderReturn(array $arguments, string $className): Return_ + { + if ($arguments === []) { + return new Return_( + $this->builderFactory->val( + 'ViewHelper ' . $className . ' content. ' + ) + ); + } + + $formatParts = []; + $vars = []; + + foreach ($arguments as $argument) { + $name = $argument[0]; + + $formatParts[] = $name . ': %s'; + $vars[] = new Variable($name); + } + + $formatString = sprintf( + 'ViewHelper %s content. The following arguments where passed: %s', + $className, + implode(', ', $formatParts) + ); + + return new Return_( + new FuncCall( + new Name('sprintf'), + array_merge( + [new Arg($this->builderFactory->val($formatString))], + array_map(fn($var): Arg => new Arg($var), $vars) + ) + ) + ); + } + + public function addInitializeArgumentsMethod(ViewHelperInformation $viewHelperInformation, FileStructure $fileStructure): void + { + $methodBuilder = $this->builderFactory + ->method('initializeArguments') + ->makePublic() + ->setReturnType('void'); + + foreach ($this->buildRegisterArgumentStatements($viewHelperInformation->getArguments()) as $stmt) { + $methodBuilder->addStmt($stmt); + } + + $fileStructure->addMethodStructure( + new MethodStructure($methodBuilder->getNode()) + ); + } +} diff --git a/Classes/Creator/ViewHelper/ViewHelperCreatorInterface.php b/Classes/Creator/ViewHelper/ViewHelperCreatorInterface.php new file mode 100644 index 0000000..9399167 --- /dev/null +++ b/Classes/Creator/ViewHelper/ViewHelperCreatorInterface.php @@ -0,0 +1,21 @@ +extensionInformation; + } + + public function getFilename(): string + { + return $this->name . 'ViewHelper.php'; + } + + public function getClassname(): string + { + return $this->name . 'ViewHelper'; + } + + public function getPath(): string + { + return $this->extensionInformation->getExtensionPath() . self::CLASS_PATH; + } + + public function getName(): string + { + return $this->name; + } + + public function isTagBased(): bool + { + return $this->tagBased; + } + + public function getTagName(): string + { + return $this->tagName; + } + + public function getArguments(): array + { + return $this->arguments; + } + + public function isEscapeOutput(): bool + { + return $this->escapeOutput; + } + + public function getNamespace(): string + { + return $this->extensionInformation->getNamespacePrefix() . self::NAMESPACE_PREFIX; + } + + public function getCreatorInformation(): CreatorInformation + { + return $this->creatorInformation; + } +} diff --git a/Classes/Service/Creator/ViewHelperCreatorService.php b/Classes/Service/Creator/ViewHelperCreatorService.php new file mode 100644 index 0000000..d3e9ead --- /dev/null +++ b/Classes/Service/Creator/ViewHelperCreatorService.php @@ -0,0 +1,28 @@ +validatorCreators as $creator) { + $creator->create($validatorInformation); + } + } +} diff --git a/Configuration/Services.yaml b/Configuration/Services.yaml index 6d1f465..a6f4bbc 100644 --- a/Configuration/Services.yaml +++ b/Configuration/Services.yaml @@ -98,6 +98,10 @@ services: arguments: $questionCollection: '@questions.validator' + FriendsOfTYPO3\Kickstarter\Command\ViewHelperCommand: + arguments: + $questionCollection: '@questions.view-helper' + ###################### ## CREATOR SERVICES ## ###################### @@ -166,6 +170,9 @@ services: FriendsOfTYPO3\Kickstarter\Service\Creator\UpgradeWizardCreatorService: arguments: $upgradeCreators: !tagged_iterator { tag: 'ext-kickstarter.creator.upgrade-wizard' } + FriendsOfTYPO3\Kickstarter\Service\Creator\ViewHelperCreatorService: + arguments: + $validatorCreators: !tagged_iterator { tag: 'ext-kickstarter.creator.view-helper' } ############### ## QUESTIONS ## @@ -279,6 +286,11 @@ services: arguments: $questions: !tagged_iterator { tag: 'ext-kickstarter.command.question.validator' } + questions.view-helper: + class: FriendsOfTYPO3\Kickstarter\Command\Input\QuestionCollection + arguments: + $questions: !tagged_iterator { tag: 'ext-kickstarter.command.question.view-helper' } + ########################### ## ASSIGN INPUT HANDLERS ## ########################### diff --git a/Tests/Functional/Integration/Fixtures/make_viewhelper/Classes/ViewHelper/ExampleViewHelper.php b/Tests/Functional/Integration/Fixtures/make_viewhelper/Classes/ViewHelper/ExampleViewHelper.php new file mode 100644 index 0000000..a6d481b --- /dev/null +++ b/Tests/Functional/Integration/Fixtures/make_viewhelper/Classes/ViewHelper/ExampleViewHelper.php @@ -0,0 +1,23 @@ +registerArgument( + 'emailAddress', + 'string', + 'The email address to resolve the gravatar for', + true, + ); + $this->registerArgument( + 'size', + 'integer', + 'The size of the gravatar, ranging from 1 to 512', + false, + 80, + ); + } + public function render(): string + { + $emailAddress = $this->arguments['emailAddress']; + $size = $this->arguments['size']; + return sprintf( + 'ViewHelper ExampleViewHelper content. The following arguments where passed: emailAddress: %s, size: %s', + $emailAddress, + $size, + ); + } +} diff --git a/Tests/Functional/Integration/ViewHelperCreatorServiceTest.php b/Tests/Functional/Integration/ViewHelperCreatorServiceTest.php new file mode 100644 index 0000000..1a6dfe8 --- /dev/null +++ b/Tests/Functional/Integration/ViewHelperCreatorServiceTest.php @@ -0,0 +1,102 @@ +instancePath . '/' . $extensionKey . '/'; + $generatedPath = $this->instancePath . '/' . $extensionKey . '/'; + + if (file_exists($generatedPath)) { + GeneralUtility::rmdir($generatedPath, true); + } + if ($inputPath !== '') { + FileSystemHelper::copyDirectory($inputPath, $generatedPath); + } + + $viewHelperInfo = new ViewHelperInformation( + extensionInformation: $this->getExtensionInformation($extensionKey, $composerPackageName, $extensionPath), + name: $name, + tagBased: $tagBased, + tagName: $tagName, + arguments: $arguments, + escapeOutput: $escapeOutput, + ); + if ($inputPath !== '') { + FileSystemHelper::copyDirectory($inputPath, $generatedPath); + } + + $creatorService = $this->get(ViewHelperCreatorService::class); + $creatorService->create($viewHelperInfo); + + self::assertCount(1, $viewHelperInfo->getCreatorInformation()->getFileModifications()); + self::assertEquals(FileModificationType::CREATED, $viewHelperInfo->getCreatorInformation()->getFileModifications()[0]->getFileModificationType()); + + // Compare generated files with fixtures + $this->assertDirectoryEquals($expectedDir, $generatedPath); + } + + public static function viewHelperCreationProvider(): array + { + return [ + 'make_viewhelper' => [ + 'extensionKey' => 'my_extension', + 'composerPackageName' => 'my-vendor/my-extension', + 'expectedDir' => __DIR__ . '/Fixtures/make_viewhelper', + 'inputPath' => __DIR__ . '/Fixtures/input/my_extension', + 'name' => 'Example', + ], + 'make_viewhelper_arguments' => [ + 'extensionKey' => 'my_extension', + 'composerPackageName' => 'my-vendor/my-extension', + 'expectedDir' => __DIR__ . '/Fixtures/make_viewhelper_arguments', + 'inputPath' => __DIR__ . '/Fixtures/input/my_extension', + 'name' => 'Example', + 'arguments' => [ + [ + 'emailAddress', + 'string', + 'The email address to resolve the gravatar for', + true, + ], + [ + 'size', + 'integer', + 'The size of the gravatar, ranging from 1 to 512', + false, + 80, + ], + ], + ], + ]; + } +}