commit c7a46689ff870de03fc5730d3d4c0502fb4a09e7 Author: Sven Wappler Date: Mon Aug 10 22:31:20 2026 +0200 TYPO3 v15 dev-main snapshot () diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..57872d0 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/vendor/ diff --git a/Classes/Command/AnalyzeCommand.php b/Classes/Command/AnalyzeCommand.php new file mode 100644 index 0000000..62dd95a --- /dev/null +++ b/Classes/Command/AnalyzeCommand.php @@ -0,0 +1,151 @@ +addOption( + 'include-system-extensions', + null, + InputOption::VALUE_NONE, + 'Include template files that belong to TYPO3 system extensions', + ); + $this->addOption( + 'stdin', + null, + InputOption::VALUE_NONE, + 'Analyze template string that is provided via STDIN', + ); + $this->addOption( + 'json', + null, + InputOption::VALUE_NONE, + 'Output results as JSON', + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $templates = $input->getOption('stdin') + ? ['php://stdin'] + : $this->templateFinder->findTemplatesInAllPackages($input->getOption('include-system-extensions')); + + if ($input->getOption('json')) { + $result = $this->validateTemplateFiles($templates); + $result = $input->getOption('stdin') ? $result['php://stdin'] : $result; + $output->writeln(json_encode($result)); + return Command::SUCCESS; + } + + $formatter = new FormatterHelper(); + $io = new SymfonyStyle($input, $output); + + $io->note('This command only analyzes templates that are using the *.fluid.* file extension.'); + + $templatesCount = count($templates); + if ($output->isVeryVerbose()) { + $io->success(sprintf('%d templates will be analyzed:', $templatesCount)); + $index = 0; + foreach ($templates as $template) { + $index++; + $output->writeln(sprintf('%d %s', $index, $template)); + } + } + $results = $this->validateTemplateFiles($templates); + $errors = $deprecations = 0; + foreach ($results as $result) { + $templateFile = $result->path; + if (str_starts_with($templateFile, Environment::getProjectPath())) { + $templateFile = substr($templateFile, strlen(Environment::getProjectPath()) + 1); + } + foreach ($result->errors as $error) { + $errors++; + $output->writeln($formatter->formatSection( + 'ERROR', + $templateFile . ': ' . $error->getMessage(), + 'error', + )); + } + foreach ($result->deprecations as $deprecation) { + $deprecations++; + $output->writeln($formatter->formatSection( + 'DEPRECATION', + $templateFile . ': ' . $deprecation->message, + 'info', + )); + } + } + if ($output->isVerbose()) { + if ($errors > 0) { + $output->writeln(''); + $io->error(sprintf('%d error(s) found in %d analyzed templates.', $errors, $templatesCount)); + } + if ($deprecations > 0) { + $output->writeln(''); + $io->warning(sprintf('%d deprecation(s) found in %d analyzed templates.', $deprecations, $templatesCount)); + } + if ($errors === 0 && $deprecations === 0) { + $io->success(sprintf('%d templates analyzed without errors or deprecations.', $templatesCount)); + } + } + return $errors > 0 ? Command::FAILURE : Command::SUCCESS; + } + + /** + * @return TemplateValidatorResult[] + */ + private function validateTemplateFiles(array $templates): array + { + return (new TemplateValidator())->validateTemplateFiles( + $templates, + $this->renderingContextFactory->create(), + ); + } +} diff --git a/Classes/Command/NamespacesCommand.php b/Classes/Command/NamespacesCommand.php new file mode 100644 index 0000000..d7d8ee0 --- /dev/null +++ b/Classes/Command/NamespacesCommand.php @@ -0,0 +1,76 @@ +addOption( + 'json', + null, + InputOption::VALUE_NONE, + 'Output namespaces as JSON', + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $globalNamespaces = $this->viewHelperResolverFactory->create()->getNamespaces(); + + if ($input->getOption('json')) { + $output->writeln(json_encode($globalNamespaces)); + return Command::SUCCESS; + } + + $table = new Table($output); + $table->setHeaders(['Alias', 'Namespace(s)']); + $isFirst = true; + foreach ($globalNamespaces as $alias => $namespaceChain) { + if (!$isFirst) { + $table->addRow(new TableSeparator()); + } + $table->addRow([ + $alias, + new TableCell(implode("\n", $namespaceChain), ['rowspan' => count($namespaceChain)]), + ]); + $isFirst = false; + } + $table->render(); + return Command::SUCCESS; + } +} diff --git a/Classes/Command/SchemaCommand.php b/Classes/Command/SchemaCommand.php new file mode 100644 index 0000000..8dbe32e --- /dev/null +++ b/Classes/Command/SchemaCommand.php @@ -0,0 +1,167 @@ +findViewHelpersInComposerProject($this->classLoader); + $errors = $viewHelperFinder->getLastErrors(); + + // Get available component definitions and merge with ViewHelpers + $viewHelperMetadataFactory = new ViewHelperMetadataFactory(); + foreach ($this->viewHelperResolverDelegateRegistry->getAll() as $delegate) { + if ( + $delegate instanceof ComponentListProviderInterface + && $delegate instanceof ComponentDefinitionProviderInterface + ) { + foreach ($delegate->getAvailableComponents() as $componentName) { + $allViewHelpers[] = $viewHelperMetadataFactory->createFromComponentDefinition( + $delegate, + $delegate->getComponentDefinition($componentName) + ); + } + } + } + + $xsdFiles = $this->combineViewHelperNamespaces($allViewHelpers, $this->viewHelperResolverFactory->create()->getNamespaces()); + + // Create transient folder if necessary + $temporaryPath = Environment::getVarPath() . '/transient/'; + if (!is_dir($temporaryPath)) { + GeneralUtility::mkdir_deep($temporaryPath); + } + + // Remove existing schema files in transient folder + $existingSchemaFiles = GeneralUtility::getFilesInDir($temporaryPath, 'xsd'); + foreach ($existingSchemaFiles as $file) { + if (str_starts_with($file, 'schema_')) { + unlink($temporaryPath . $file); + } + } + + // Write schema files to transient folder + foreach ($xsdFiles as $xmlNamespace => $viewHelpers) { + $schema = (new SchemaGenerator())->generate($xmlNamespace, $viewHelpers); + $fileName = str_replace('http://typo3.org/ns/', '', $xmlNamespace); + $fileName = str_replace('/', '_', $fileName); + $fileName = preg_replace('#[^0-9a-zA-Z_]#', '', $fileName); + GeneralUtility::writeFile($temporaryPath . 'schema_' . $fileName . '.xsd', $schema->asXml(), true); + $output->writeln(sprintf('Generated schema file %s', $temporaryPath . 'schema_' . $fileName . '.xsd'), OutputInterface::VERBOSITY_DEBUG); + } + + if ($errors !== []) { + $output->writeln('Reported errors:'); + $table = new Table($output); + $table->setHeaders(['Class', 'Message']); + foreach ($errors as $error) { + $table->addRow([ + $error->getFile(), + $error->getMessage(), + ]); + } + $table->render(); + $output->writeln('Successfully generated all schemas except those listed with errors.'); + } + + return Command::SUCCESS; + } + + /** + * @param ViewHelperMetadata[] $viewHelpers + * @param array $globalNamespaces + * @return array + */ + private static function combineViewHelperNamespaces(array $viewHelpers, array $globalNamespaces): array + { + // Group ViewHelpers by xml namespace to split them into xsd files later + $viewHelperNamespaces = $groupedByNamespace = []; + foreach ($viewHelpers as $viewHelper) { + $viewHelperNamespaces[$viewHelper->xmlNamespace] ??= []; + $viewHelperNamespaces[$viewHelper->xmlNamespace][] = $viewHelper; + + $groupedByNamespace[$viewHelper->namespace] ??= []; + $groupedByNamespace[$viewHelper->namespace][] = $viewHelper; + } + + // Special handling of TYPO3's global ViewHelper namespaces which allows + // merging of several PHP namespaces into one Fluid namespace. If a configured + // global Fluid namespace has more than one PHP namespace, ViewHelpers can be + // overridden by subsequent namespaces if they are defined with the same name. + // For example, both Fluid Standalone and EXT:fluid define , + // but EXT:fluid is the higher item in the namespace array, so it will be part + // of the xsd file, while the from Fluid Standalone will be omitted. + foreach ($globalNamespaces as $mergedNamespace) { + // If a global namespace has only one item, it is already covered by the + // default handling above + if (count($mergedNamespace) < 2) { + continue; + } + + // Last PHP namespace defines the xml namespace + $targetNamespace = end($mergedNamespace); + if (!isset($groupedByNamespace[$targetNamespace])) { + continue; + } + $xmlNamespace = $groupedByNamespace[$targetNamespace][0]->xmlNamespace; + + // Combine PHP namespaces into one XML namespace; basically, all previous + // namespaces are "pulled" into the current namespace and then overlayed with + // it, so that ViewHelpers with the same name can override + $viewHelperNamespaces[$xmlNamespace] = []; + foreach ($mergedNamespace as $namespace) { + foreach ($groupedByNamespace[$namespace] ?? [] as $viewHelper) { + $viewHelperNamespaces[$xmlNamespace][$viewHelper->tagName] = $viewHelper; + } + } + $viewHelperNamespaces[$xmlNamespace] = array_values($viewHelperNamespaces[$xmlNamespace]); + } + return $viewHelperNamespaces; + } +} diff --git a/Classes/Command/WarmupCommand.php b/Classes/Command/WarmupCommand.php new file mode 100644 index 0000000..4d431ea --- /dev/null +++ b/Classes/Command/WarmupCommand.php @@ -0,0 +1,87 @@ +note('This command only considers templates that are using the *.fluid.* file extension.'); + $errors = $deprecations = 0; + $results = $this->cacheWarmupService->warmupTemplatesInAllPackages(); + $templatesCount = count($results); + foreach ($results as $result) { + $templateFile = $result->path; + if (str_starts_with($templateFile, Environment::getProjectPath())) { + $templateFile = substr($templateFile, strlen(Environment::getProjectPath()) + 1); + } + foreach ($result->errors as $error) { + $errors++; + $output->writeln($formatter->formatSection( + 'ERROR', + $templateFile . ': ' . $error->getMessage(), + 'error', + )); + } + foreach ($result->deprecations as $deprecation) { + $deprecations++; + $output->writeln($formatter->formatSection( + 'DEPRECATION', + $templateFile . ': ' . $deprecation->message, + 'info', + )); + } + } + if ($output->isVerbose()) { + if ($errors > 0) { + $output->writeln(''); + $io->error(sprintf('%d error(s) found in %d warmed up templates.', $errors, $templatesCount)); + } + if ($deprecations > 0) { + $output->writeln(''); + $io->warning(sprintf('%d deprecation(s) found in %d warmed up templates.', $deprecations, $templatesCount)); + } + if ($errors === 0 && $deprecations === 0) { + $io->success(sprintf('%d templates warmed up without errors or deprecations.', $templatesCount)); + } + } + return $errors > 0 ? Command::FAILURE : Command::SUCCESS; + } +} diff --git a/Classes/ConfigurationModuleProvider/NamespacesProvider.php b/Classes/ConfigurationModuleProvider/NamespacesProvider.php new file mode 100644 index 0000000..3f3af91 --- /dev/null +++ b/Classes/ConfigurationModuleProvider/NamespacesProvider.php @@ -0,0 +1,34 @@ +viewHelperResolverFactory->create()->getNamespaces(); + } +} diff --git a/Classes/Core/Cache/FluidTemplateCache.php b/Classes/Core/Cache/FluidTemplateCache.php new file mode 100644 index 0000000..f72dc96 --- /dev/null +++ b/Classes/Core/Cache/FluidTemplateCache.php @@ -0,0 +1,63 @@ +requireOnce($entryIdentifier); + } + + /** + * @param string $entryIdentifier + * @param string $sourceCode + * @param int $lifetime + * @throws InvalidDataException + */ + public function set($entryIdentifier, $sourceCode, array $tags = [], $lifetime = null): void + { + if (str_starts_with($sourceCode, ' + */ + private array $componentCollections; + + public function __construct( + #[Autowire(service: 'cache.fluid_component_definitions')] + private FrontendInterface $componentDefinitionsCache, + private EventDispatcherInterface $eventDispatcher, + #[Autowire(service: 'fluid.component.collections')] + iterable $componentCollectionsConfig, + ) { + $componentCollections = []; + foreach ($componentCollectionsConfig as $namespace => $config) { + $componentCollections[$namespace] = $this->createComponentCollectionObject($namespace, $config); + } + $this->componentCollections = $componentCollections; + } + + /** + * @return array + */ + public function getAll(): array + { + return $this->componentCollections; + } + + private function createComponentCollectionObject(string $namespace, array $config): DeclarativeComponentCollection + { + if (!isset($config['templatePaths']) || !is_array($config['templatePaths']) || $config['templatePaths'] === []) { + throw new \RuntimeException(sprintf( + 'Invalid or empty template paths provided for Fluid component collection "%s". At least one template path needs to be specified in Configuration/Fluid/ComponentCollections.php.', + $namespace, + ), 1768473237); + } + $componentCollection = new DeclarativeComponentCollection( + $this->componentDefinitionsCache, + $this->eventDispatcher, + $namespace, + $config['templatePaths'], + ); + if (array_key_exists('templateNamePattern', $config)) { + $componentCollection = $componentCollection->withTemplateNamePattern($config['templateNamePattern']); + } + if (array_key_exists('additionalArgumentsAllowed', $config)) { + $componentCollection = $componentCollection->withAdditionalArgumentsAllowed($config['additionalArgumentsAllowed']); + } + return $componentCollection; + } +} diff --git a/Classes/Core/Component/DeclarativeComponentCollection.php b/Classes/Core/Component/DeclarativeComponentCollection.php new file mode 100644 index 0000000..6dd74dd --- /dev/null +++ b/Classes/Core/Component/DeclarativeComponentCollection.php @@ -0,0 +1,200 @@ +templateNamePattern = trim($templateNamePattern, '/'); + } + + public function withTemplateNamePattern(string $templateNamePattern): static + { + return new static($this->cache, $this->eventDispatcher, $this->namespace, $this->templatePaths, $templateNamePattern, $this->additionalArgumentsAllowed); + } + + public function withAdditionalArgumentsAllowed(bool $additionalArgumentsAllowed): static + { + return new static($this->cache, $this->eventDispatcher, $this->namespace, $this->templatePaths, $this->templateNamePattern, $additionalArgumentsAllowed); + } + + public function resolveTemplateName(string $viewHelperName): string + { + $fragments = array_map(ucfirst(...), explode('.', $viewHelperName)); + $name = array_pop($fragments); + $path = implode('/', $fragments); + return ltrim(str_replace(['{path}', '{name}'], [$path, $name], $this->templateNamePattern), '/'); + } + + public function getAvailableComponents(): array + { + $availableTemplates = $this->getTemplatePaths()->resolveAvailableTemplateFiles(null, null, true); + $templateNamePattern = self::convertTemplatePatternToRegularExpression($this->templateNamePattern); + $availableComponents = []; + foreach ($availableTemplates as $templatePath) { + // Remove template root path + foreach ($this->getTemplatePaths()->getTemplateRootPaths() as $rootPath) { + if (str_starts_with($templatePath, $rootPath)) { + $templatePath = substr($templatePath, strlen($rootPath)); + break; + } + } + // Convert template name into ViewHelper name and validate directory structure + // (resolveTemplateName() in reverse) + if (!preg_match($templateNamePattern, $templatePath, $matches)) { + continue; + } + $fragments = $matches['path'] ? GeneralUtility::trimExplode('/', $matches['path'], true) : []; + $fragments[] = $matches['name']; + $availableComponents[] = implode('.', array_map(lcfirst(...), $fragments)); + } + return array_values(array_unique($availableComponents)); + } + + public function getTemplatePaths(): TemplatePaths + { + $templatePaths = new TemplatePaths(); + $templatePaths->setTemplateRootPaths($this->templatePaths); + return $templatePaths; + } + + public function getAdditionalVariables(string $viewHelperName): array + { + // Allow to provide additional variables to the component template. + // Note that this deliberately cannot depend on runtime characteristics, + // such as the request, as this should be done in the renderer. + $event = $this->eventDispatcher->dispatch( + new ProvideStaticVariablesToComponentEvent($this, $viewHelperName) + ); + return $event->getStaticVariables(); + } + + public function getComponentDefinition(string $viewHelperName): ComponentDefinition + { + $cacheIdentifier = hash('xxh3', $this->namespace . '--' . $viewHelperName); + $componentDefinition = $this->cache->get($cacheIdentifier); + if ($componentDefinition instanceof ComponentDefinition) { + return $componentDefinition; + } + $templateName = $this->resolveTemplateName($viewHelperName); + /** + * Extract component definition from component template + * This part is an ugly workaround because of shortcomings in the Fluid parser. + * Once this has been resolved on the Fluid side, there will most likely be a better API. + * @see \TYPO3Fluid\Fluid\Core\Component\AbstractComponentCollection + */ + $renderingContext = new RenderingContext(); + $renderingContext->setViewHelperResolver(new TemplateStructureViewHelperResolver()); + $parsedTemplate = $renderingContext->getTemplateParser()->parse( + $this->getTemplatePaths()->getTemplateSource('Default', $templateName), + $this->getTemplatePaths()->getTemplateIdentifier('Default', $templateName), + $this->getTemplatePaths()->resolveTemplateFileForControllerAndActionAndFormat('Default', $templateName), + ); + $componentDefinition = new ComponentDefinition( + $viewHelperName, + $parsedTemplate->getArgumentDefinitions(), + $this->additionalArgumentsAllowed, + $parsedTemplate->getAvailableSlots(), + ); + // Allow modification of component definition before it's written to cache. + // Note that this deliberately cannot depend on runtime characteristics, + // such as the request, as this should be done during rendering (e. g. by allowing + // arbitrary arguments) + $event = $this->eventDispatcher->dispatch( + new ModifyComponentDefinitionEvent($this->namespace, $componentDefinition) + ); + $componentDefinition = $event->getComponentDefinition(); + $this->cache->set($cacheIdentifier, $componentDefinition); + return $componentDefinition; + } + + public function getComponentRenderer(): ComponentRendererInterface + { + return new EventBasedComponentRenderer($this->eventDispatcher, $this); + } + + public function resolveViewHelperClassName(string $name): string + { + $expectedTemplateName = $this->resolveTemplateName($name); + try { + $this->getTemplatePaths()->resolveTemplateFileForControllerAndActionAndFormat('Default', $expectedTemplateName, null, true); + } catch (InvalidTemplateResourceException $e) { + throw new UnresolvableViewHelperException(sprintf( + 'The component template "%s" in format ".%s" could not be found in the configured template paths. %s', + $expectedTemplateName, + $this->getTemplatePaths()->getFormat(), + $e->evaluatedTemplatePaths !== [] ? 'The following file paths were evaluated: "' . implode('", "', $e->evaluatedTemplatePaths) . '"' : 'No paths configured.', + ), 1765711586); + } + return ComponentAdapter::class; + } + + public function getNamespace(): string + { + return $this->namespace; + } + + private static function convertTemplatePatternToRegularExpression(string $templateNamePattern): string + { + $delimiter = '~'; + $pathMarker = preg_quote('{path}/', $delimiter); + $nameMarker = preg_quote('{name}', $delimiter); + $templateNamePattern = preg_quote($templateNamePattern, $delimiter); + if (str_contains($templateNamePattern, $pathMarker)) { + [$beforePath, $afterPath] = explode($pathMarker, $templateNamePattern, 2); + $templateNamePattern = $beforePath . '(?(?:.+?/)?)' . str_replace($pathMarker, '(?P=path)', $afterPath); + } + if (str_contains($templateNamePattern, $nameMarker)) { + [$beforeName, $afterName] = explode($nameMarker, $templateNamePattern, 2); + $templateNamePattern = $beforeName . '(?[^/]+?)' . str_replace($nameMarker, '(?P=name)', $afterName); + } + return $delimiter . '^' . $templateNamePattern . '$' . $delimiter; + } +} diff --git a/Classes/Core/Component/EventBasedComponentRenderer.php b/Classes/Core/Component/EventBasedComponentRenderer.php new file mode 100644 index 0000000..a534ab1 --- /dev/null +++ b/Classes/Core/Component/EventBasedComponentRenderer.php @@ -0,0 +1,61 @@ +hasAttribute(ServerRequestInterface::class) + ? $parentRenderingContext->getAttribute(ServerRequestInterface::class) + : null; + $event = $this->eventDispatcher->dispatch( + new RenderComponentEvent($this->componentCollection, $viewHelperName, $arguments, $slots, $parentRenderingContext, $request) + ); + if ($event->getRenderedComponent() !== null) { + return $event->getRenderedComponent(); + } + return (new FluidComponentRenderer($this->componentCollection))->renderComponent( + $viewHelperName, + $event->getArguments(), + $event->getSlots(), + $parentRenderingContext, + ); + } +} diff --git a/Classes/Core/Rendering/RenderingContext.php b/Classes/Core/Rendering/RenderingContext.php new file mode 100644 index 0000000..b511add --- /dev/null +++ b/Classes/Core/Rendering/RenderingContext.php @@ -0,0 +1,120 @@ +create()` instead + */ + public function __construct( + ViewHelperResolver $viewHelperResolver, + FluidCacheInterface $cache, + array $templateProcessors, + array $expressionNodeTypes, + TemplatePaths $templatePaths, + ArgumentProcessorInterface $argumentProcessor + ) { + // Partially cloning parent::__construct() but with custom implementations. + $this->setTemplateParser(new TemplateParser()); + $this->setTemplateCompiler(new TemplateCompiler()); + $this->setViewHelperInvoker(new ViewHelperInvoker()); + $this->setArgumentProcessor($argumentProcessor); + $this->setViewHelperVariableContainer(new ViewHelperVariableContainer()); + $this->setVariableProvider(new StandardVariableProvider()); + $this->setTemplateProcessors($templateProcessors); + $this->setExpressionNodeTypes($expressionNodeTypes); + $this->setTemplatePaths($templatePaths); + $this->setViewHelperResolver($viewHelperResolver); + $this->setCache($cache); + } + + /** + * Build parser configuration. Adds custom fluid interceptors from configuration. + * + * @throws \InvalidArgumentException if a class not implementing InterceptorInterface was registered + */ + public function buildParserConfiguration(): Configuration + { + $parserConfiguration = parent::buildParserConfiguration(); + foreach ($GLOBALS['TYPO3_CONF_VARS']['SYS']['fluid']['interceptors'] as $className) { + $interceptor = GeneralUtility::makeInstance($className); + if (!$interceptor instanceof InterceptorInterface) { + throw new \InvalidArgumentException( + 'Interceptor "' . $className . '" needs to implement ' . InterceptorInterface::class . '.', + 1462869795 + ); + } + $parserConfiguration->addInterceptor($interceptor); + } + return $parserConfiguration; + } + + /** + * @param string $action + */ + public function setControllerAction($action): void + { + $dotPosition = strpos($action, '.'); + if ($dotPosition !== false) { + $action = substr($action, 0, $dotPosition); + } + $this->controllerAction = $action; + } + + /** + * @param string $controllerName + */ + public function setControllerName($controllerName): void + { + $this->controllerName = $controllerName; + } + + public function getControllerName(): string + { + return $this->controllerName; + } + + public function getControllerAction(): string + { + return $this->controllerAction; + } +} diff --git a/Classes/Core/Rendering/RenderingContextFactory.php b/Classes/Core/Rendering/RenderingContextFactory.php new file mode 100644 index 0000000..7d4e622 --- /dev/null +++ b/Classes/Core/Rendering/RenderingContextFactory.php @@ -0,0 +1,112 @@ +container instanceof FailsafeContainer) { + // Load default set of processors in failsafe mode (install tool context) + // as custom processors can not be retrieved from the symfony container + $processors = [ + new EscapingModifierTemplateProcessor(), + new PassthroughSourceModifierTemplateProcessor(), + new NamespaceDetectionTemplateProcessor(), + new RemoveCommentsTemplateProcessor(), + ]; + } else { + foreach ($GLOBALS['TYPO3_CONF_VARS']['SYS']['fluid']['preProcessors'] as $className) { + /** @var TemplateProcessorInterface[] $processors */ + $processors[] = $this->container->get($className); + } + } + + $cache = $this->cacheManager->getCache('fluid_template'); + if (!$cache instanceof FluidCacheInterface) { + throw new \RuntimeException('Cache fluid_template must implement FluidCacheInterface', 1623148753); + } + + $templatePaths = new TemplatePaths(); + if (!empty($templatePathsArray['templateRootPaths'])) { + $templatePaths->setTemplateRootPaths($templatePathsArray['templateRootPaths']); + } + if (!empty($templatePathsArray['layoutRootPaths'])) { + $templatePaths->setLayoutRootPaths($templatePathsArray['layoutRootPaths']); + } + if (!empty($templatePathsArray['partialRootPaths'])) { + $templatePaths->setPartialRootPaths($templatePathsArray['partialRootPaths']); + } + + $renderingContext = new RenderingContext( + $this->viewHelperResolverFactory->create(), + $cache, + $processors, + $GLOBALS['TYPO3_CONF_VARS']['SYS']['fluid']['expressionNodeTypes'], + $templatePaths, + $this->argumentProcessor, + ); + if ($request) { + $renderingContext->setAttribute(ServerRequestInterface::class, $request); + } + return $renderingContext; + } +} diff --git a/Classes/Core/ViewHelper/ViewHelperResolver.php b/Classes/Core/ViewHelper/ViewHelperResolver.php new file mode 100644 index 0000000..94be6a5 --- /dev/null +++ b/Classes/Core/ViewHelper/ViewHelperResolver.php @@ -0,0 +1,90 @@ +create()` instead + */ + public function __construct(ContainerInterface $container, array $namespaces, array $resolverDelegates = []) + { + $this->container = $container; + $this->namespaces = $namespaces; + $this->resolverDelegates = $resolverDelegates; + } + + /** + * @param string $viewHelperClassName + */ + public function createViewHelperInstanceFromClassName($viewHelperClassName): ViewHelperInterface + { + if ($this->container instanceof FailsafeContainer) { + // Install tool: makeInstance() resolves via the FailsafeContainer when the VH is + // registered there, else via `new`. VHs with required constructor arguments used by + // install-tool templates must be wired in install/Classes/ServiceProvider.php. + /** @var ViewHelperInterface $viewHelperInstance */ + $viewHelperInstance = GeneralUtility::makeInstance($viewHelperClassName); + return $viewHelperInstance; + } + + if ($this->container->has($viewHelperClassName)) { + /** @var ViewHelperInterface $viewHelperInstance */ + $viewHelperInstance = $this->container->get($viewHelperClassName); + } else { + /** @var ViewHelperInterface $viewHelperInstance */ + $viewHelperInstance = new $viewHelperClassName(); + } + return $viewHelperInstance; + } +} diff --git a/Classes/Core/ViewHelper/ViewHelperResolverDelegateRegistry.php b/Classes/Core/ViewHelper/ViewHelperResolverDelegateRegistry.php new file mode 100644 index 0000000..65f6a67 --- /dev/null +++ b/Classes/Core/ViewHelper/ViewHelperResolverDelegateRegistry.php @@ -0,0 +1,55 @@ + + */ + private array $viewHelperResolverDelegates; + + public function __construct( + ComponentCollectionRegistry $componentCollectionRegistry, + #[AutowireIterator('fluid.resolverdelegate', exclude: [DeclarativeComponentCollection::class], indexAttribute: 'identifier')] + iterable $resolverDelegates, + ) { + $this->viewHelperResolverDelegates = array_replace( + iterator_to_array($resolverDelegates), + $componentCollectionRegistry->getAll(), + ); + } + + /** + * @return array + */ + public function getAll(): array + { + return $this->viewHelperResolverDelegates; + } +} diff --git a/Classes/Core/ViewHelper/ViewHelperResolverFactory.php b/Classes/Core/ViewHelper/ViewHelperResolverFactory.php new file mode 100644 index 0000000..8a8c2da --- /dev/null +++ b/Classes/Core/ViewHelper/ViewHelperResolverFactory.php @@ -0,0 +1,56 @@ +eventDispatcher->dispatch(new ModifyNamespacesEvent((array)$this->namespaces)); + return new ViewHelperResolver( + $this->container, + $event->getNamespaces(), + $this->viewHelperResolverDelegateRegistry instanceof ViewHelperResolverDelegateRegistry ? iterator_to_array($this->viewHelperResolverDelegateRegistry->getAll()) : [], + ); + } +} diff --git a/Classes/Core/ViewHelper/ViewHelperResolverFactoryInterface.php b/Classes/Core/ViewHelper/ViewHelperResolverFactoryInterface.php new file mode 100644 index 0000000..34197df --- /dev/null +++ b/Classes/Core/ViewHelper/ViewHelperResolverFactoryInterface.php @@ -0,0 +1,29 @@ +namespace; + } + + public function getComponentDefinition(): ComponentDefinition + { + return $this->componentDefinition; + } + + public function setComponentDefinition(ComponentDefinition $componentDefinition): void + { + $this->componentDefinition = $componentDefinition; + } +} diff --git a/Classes/Event/ModifyNamespacesEvent.php b/Classes/Event/ModifyNamespacesEvent.php new file mode 100644 index 0000000..fc826de --- /dev/null +++ b/Classes/Event/ModifyNamespacesEvent.php @@ -0,0 +1,46 @@ + $namespaces + */ + public function __construct(private array $namespaces) {} + + /** + * @return array + */ + public function getNamespaces(): array + { + return $this->namespaces; + } + + /** + * @param array $namespaces + */ + public function setNamespaces(array $namespaces): void + { + $this->namespaces = $namespaces; + } +} diff --git a/Classes/Event/ModifyRenderedContentAreaEvent.php b/Classes/Event/ModifyRenderedContentAreaEvent.php new file mode 100644 index 0000000..4001200 --- /dev/null +++ b/Classes/Event/ModifyRenderedContentAreaEvent.php @@ -0,0 +1,59 @@ +renderedContentArea; + } + + /** + * Set the rendered content area's HTML. + * Make sure to return escaped content if necessary. + */ + public function setRenderedContentArea(string $renderedContentArea): void + { + $this->renderedContentArea = $renderedContentArea; + } + + public function getContentArea(): ContentArea + { + return $this->contentArea; + } + + public function getRequest(): ServerRequestInterface + { + return $this->request; + } +} diff --git a/Classes/Event/ModifyRenderedRecordEvent.php b/Classes/Event/ModifyRenderedRecordEvent.php new file mode 100644 index 0000000..f03d000 --- /dev/null +++ b/Classes/Event/ModifyRenderedRecordEvent.php @@ -0,0 +1,59 @@ +renderedRecord; + } + + /** + * Set the rendered record's HTML. + * Make sure to return escaped content if necessary. + */ + public function setRenderedRecord(string $renderedRecord): void + { + $this->renderedRecord = $renderedRecord; + } + + public function getRecord(): RecordInterface + { + return $this->record; + } + + public function getRequest(): ServerRequestInterface + { + return $this->request; + } +} diff --git a/Classes/Event/ProvideStaticVariablesToComponentEvent.php b/Classes/Event/ProvideStaticVariablesToComponentEvent.php new file mode 100644 index 0000000..b7f7889 --- /dev/null +++ b/Classes/Event/ProvideStaticVariablesToComponentEvent.php @@ -0,0 +1,70 @@ + + */ + private $staticVariables = []; + + public function __construct( + private readonly ViewHelperResolverDelegateInterface&ComponentDefinitionProviderInterface&ComponentTemplateResolverInterface $componentCollection, + private readonly string $viewHelperName, + ) {} + + public function getComponentCollection(): ViewHelperResolverDelegateInterface&ComponentDefinitionProviderInterface&ComponentTemplateResolverInterface + { + return $this->componentCollection; + } + + public function getViewHelperName(): string + { + return $this->viewHelperName; + } + + /** + * @return array + */ + public function getStaticVariables(): array + { + return $this->staticVariables; + } + + /** + * @param array $staticVariables + */ + public function setStaticVariables(array $staticVariables): void + { + $this->staticVariables = $staticVariables; + } +} diff --git a/Classes/Event/RenderComponentEvent.php b/Classes/Event/RenderComponentEvent.php new file mode 100644 index 0000000..fa6dad3 --- /dev/null +++ b/Classes/Event/RenderComponentEvent.php @@ -0,0 +1,128 @@ + $arguments + * @param array $slots + */ + public function __construct( + private readonly ViewHelperResolverDelegateInterface&ComponentDefinitionProviderInterface&ComponentTemplateResolverInterface $componentCollection, + private readonly string $viewHelperName, + private array $arguments, + private array $slots, + private readonly RenderingContextInterface $parentRenderingContext, + private readonly ?ServerRequestInterface $request, + ) {} + + public function getComponentCollection(): ViewHelperResolverDelegateInterface&ComponentDefinitionProviderInterface&ComponentTemplateResolverInterface + { + return $this->componentCollection; + } + + public function getViewHelperName(): string + { + return $this->viewHelperName; + } + + public function getParentRenderingContext(): RenderingContextInterface + { + return $this->parentRenderingContext; + } + + public function getRequest(): ?ServerRequestInterface + { + return $this->request; + } + + /** + * @return array + */ + public function getArguments(): array + { + return $this->arguments; + } + + /** + * @param array $arguments + */ + public function setArguments(array $arguments): void + { + $this->arguments = $arguments; + } + + /** + * @return array + */ + public function getSlots(): array + { + return $this->slots; + } + + /** + * @param array $slots + */ + public function setSlots(array $slots): void + { + $this->slots = $slots; + } + + public function setRenderedComponent(string $renderedComponent): void + { + $this->renderedComponent = $renderedComponent; + } + + public function getRenderedComponent(): ?string + { + return $this->renderedComponent; + } + + public function isPropagationStopped(): bool + { + return $this->renderedComponent !== null; + } +} diff --git a/Classes/EventListener/CacheWarmupEventListener.php b/Classes/EventListener/CacheWarmupEventListener.php new file mode 100644 index 0000000..aab1f02 --- /dev/null +++ b/Classes/EventListener/CacheWarmupEventListener.php @@ -0,0 +1,38 @@ +hasGroup('system')) { + $this->cacheWarmupService->warmupTemplatesInAllPackages(); + } + } +} diff --git a/Classes/Service/CacheWarmupService.php b/Classes/Service/CacheWarmupService.php new file mode 100644 index 0000000..75191df --- /dev/null +++ b/Classes/Service/CacheWarmupService.php @@ -0,0 +1,59 @@ +templateFinder->findTemplatesInAllPackages(); + $validationResults = (new TemplateValidator())->validateTemplateFiles( + $templates, + $this->renderingContextFactory->create() + ); + foreach ($validationResults as &$result) { + if ($result->canBeCompiled()) { + try { + $this->renderingContextFactory->create()->getTemplateCompiler()->store( + $result->identifier, + $result->parsedTemplate, + $result->path, + ); + } catch (\Exception $e) { + $result = $result->withErrors([...$result->errors, $e]); + } + } + } + return $validationResults; + } +} diff --git a/Classes/Service/TemplateFinder.php b/Classes/Service/TemplateFinder.php new file mode 100644 index 0000000..7f11047 --- /dev/null +++ b/Classes/Service/TemplateFinder.php @@ -0,0 +1,74 @@ +files() + ->in($this->getPackagePaths($includeFrameworkPackages)) + ->exclude([ + 'Classes', + 'Tests', + 'node_modules', + 'vendor', + ]) + ->name('*.fluid.*'); + return array_map( + fn(SplFileInfo $file): string => $file->getPathname(), + iterator_to_array($templates), + ); + } + + /** + * @return string[] + */ + private function getPackagePaths(bool $includeFrameworkPackages): array + { + $activePackages = $this->packageManager->getActivePackages(); + if (!$includeFrameworkPackages) { + $activePackages = array_filter( + $activePackages, + fn(PackageInterface $package): bool => !$package->getPackageMetaData()->isFrameworkType(), + ); + } + return array_map( + fn(PackageInterface $package): string => $package->getPackagePath(), + $activePackages, + ); + } +} diff --git a/Classes/ServiceProvider.php b/Classes/ServiceProvider.php new file mode 100644 index 0000000..0074bc9 --- /dev/null +++ b/Classes/ServiceProvider.php @@ -0,0 +1,132 @@ + self::getRenderingContextFactory(...), + Core\ViewHelper\ViewHelperResolverFactory::class => self::getViewHelperResolverFactory(...), + Core\ViewHelper\ViewHelperResolverFactoryInterface::class => self::getViewHelperResolverFactoryInterface(...), + ]; + } + + public function getExtensions(): array + { + return [ + ViewFactoryInterface::class => self::provideFallbackViewFactory(...), + ViewHelpers\ResourceViewHelper::class => self::provideFallbackResourceViewHelper(...), + ViewHelpers\Uri\ResourceViewHelper::class => self::provideFallbackResourceUriViewHelper(...), + ArgumentProcessorInterface::class => self::provideFallbackArgumentProcessor(...), + ] + parent::getExtensions(); + } + + public static function getRenderingContextFactory(ContainerInterface $container): Core\Rendering\RenderingContextFactory + { + return self::new($container, Core\Rendering\RenderingContextFactory::class, [ + $container, + $container->get(CacheManager::class), + $container->get(Core\ViewHelper\ViewHelperResolverFactoryInterface::class), + $container->get(ArgumentProcessorInterface::class), + ]); + } + + public static function getViewHelperResolverFactory(ContainerInterface $container): Core\ViewHelper\ViewHelperResolverFactory + { + return self::new($container, Core\ViewHelper\ViewHelperResolverFactory::class, [ + $container, + $container->get(EventDispatcherInterface::class), + // Don't provide resolver delegates (including component collections) to InstallTool + // because it currently doesn't use components and can avoid that additional complexity + $container->has(ViewHelperResolverDelegateRegistry::class) ? $container->get(ViewHelperResolverDelegateRegistry::class) : null, + $container->get('fluid.namespaces'), + ]); + } + + public static function getViewHelperResolverFactoryInterface(ContainerInterface $container): Core\ViewHelper\ViewHelperResolverFactoryInterface + { + return $container->get(Core\ViewHelper\ViewHelperResolverFactory::class); + } + + public static function provideFallbackViewFactory( + ContainerInterface $container, + ?ViewFactoryInterface $viewFactory = null + ): ViewFactoryInterface { + // Provide the default FluidViewFactory for the install tool when $viewFactory is null (that means when we run without symfony DI) + return $viewFactory ?? new View\FluidViewFactory( + $container->get(Core\Rendering\RenderingContextFactory::class), + ); + } + + public static function provideFallbackResourceUriViewHelper( + ContainerInterface $container, + ?ViewHelpers\Uri\ResourceViewHelper $resourceViewHelper = null + ): ViewHelpers\Uri\ResourceViewHelper { + // Provide the ResourceViewHelper for the install tool when $resourceViewHelper is null (that means when we run without symfony DI) + return $resourceViewHelper ?? new ViewHelpers\Uri\ResourceViewHelper( + $container->get(SystemResourceFactory::class), + $container->get(SystemResourcePublisherInterface::class), + $container->get(SystemResourceIdentifierFactory::class), + ); + } + + public static function provideFallbackResourceViewHelper( + ContainerInterface $container, + ?ViewHelpers\ResourceViewHelper $resourceViewHelper = null + ): ViewHelpers\ResourceViewHelper { + // Provide the ResourceViewHelper for the install tool when $resourceViewHelper is null (that means when we run without symfony DI) + return $resourceViewHelper ?? new ViewHelpers\ResourceViewHelper( + $container->get(SystemResourceFactory::class), + ); + } + + public static function provideFallbackArgumentProcessor( + ContainerInterface $container, + ?ArgumentProcessorInterface $argumentProcessor = null + ): ArgumentProcessorInterface { + // Provide the default argument processor for the install tool when $argumentProcessor is null (that means when we run without symfony DI) + return $argumentProcessor ?? new StrictArgumentProcessor(); + } +} diff --git a/Classes/View/FluidViewAdapter.php b/Classes/View/FluidViewAdapter.php new file mode 100644 index 0000000..8c6a3e5 --- /dev/null +++ b/Classes/View/FluidViewAdapter.php @@ -0,0 +1,90 @@ +view->assign($key, $value); + return $this; + } + + public function assignMultiple(array $values): self + { + $this->view->assignMultiple($values); + return $this; + } + + public function render(string $templateFileName = ''): string + { + $renderedView = $this->view->render($templateFileName); + if ($renderedView !== null && !is_scalar($renderedView) && !$renderedView instanceof \Stringable) { + throw new \RuntimeException('The rendered Fluid view can not be turned into string', 1731959329); + } + return (string)$renderedView; + } + + public function getRenderingContext(): RenderingContextInterface + { + if ($this->view instanceof FluidStandaloneAbstractTemplateView) { + return $this->view->getRenderingContext(); + } + throw new \RuntimeException('view must be an instance of ext:fluid \TYPO3Fluid\Fluid\View\AbstractTemplateView', 1721889095); + } + + public function setRenderingContext(RenderingContextInterface $renderingContext): void + { + if ($this->view instanceof FluidStandaloneAbstractTemplateView) { + $this->view->setRenderingContext($renderingContext); + return; + } + throw new \RuntimeException('view must be an instance of ext:fluid \TYPO3Fluid\Fluid\View\AbstractTemplateView', 1721578954); + } + + public function renderSection($sectionName, array $variables = [], $ignoreUnknown = false): mixed + { + if ($this->view instanceof FluidStandaloneAbstractTemplateView) { + return $this->view->renderSection($sectionName, $variables, $ignoreUnknown); + } + throw new \RuntimeException('view must be an instance of ext:fluid \TYPO3Fluid\Fluid\View\AbstractTemplateView', 1721746411); + } + + public function renderPartial($partialName, $sectionName, array $variables, $ignoreUnknown = false): mixed + { + if ($this->view instanceof FluidStandaloneAbstractTemplateView) { + return $this->view->renderPartial($partialName, $sectionName, $variables, $ignoreUnknown); + } + throw new \RuntimeException('view must be an instance of ext:fluid \TYPO3Fluid\Fluid\View\AbstractTemplateView', 1721746412); + } +} diff --git a/Classes/View/FluidViewFactory.php b/Classes/View/FluidViewFactory.php new file mode 100644 index 0000000..c9ded18 --- /dev/null +++ b/Classes/View/FluidViewFactory.php @@ -0,0 +1,67 @@ +templateRootPaths)) { + $pathTuple['templateRootPaths'] = $data->templateRootPaths; + } + if (!empty($data->layoutRootPaths)) { + $pathTuple['layoutRootPaths'] = $data->layoutRootPaths; + } + if (!empty($data->partialRootPaths)) { + $pathTuple['partialRootPaths'] = $data->partialRootPaths; + } + $renderingContext = $this->renderingContextFactory->create($pathTuple, $data->request); + if ($data->templatePathAndFilename) { + $renderingContext->getTemplatePaths()->setTemplatePathAndFilename($data->templatePathAndFilename); + } + if ($data->format) { + // @todo: We may want to hand this over to RenderingContextFactory + // and set up TemplatePaths() with the format correctly already? + $renderingContext->getTemplatePaths()->setFormat($data->format); + } + $view = new TemplateView($renderingContext); + return new FluidViewAdapter($view); + } +} diff --git a/Classes/View/TemplatePaths.php b/Classes/View/TemplatePaths.php new file mode 100644 index 0000000..1fafad2 --- /dev/null +++ b/Classes/View/TemplatePaths.php @@ -0,0 +1,79 @@ +templatePathAndFilename; + } + + /** + * Guarantees that $reference is turned into a + * correct, absolute path. The input can be a + * relative path or a FILE: or EXT: reference + * but cannot be a FAL resource identifier. + * + * @param string $reference + */ + protected function ensureAbsolutePath($reference): string + { + return PathUtility::isAbsolutePath($reference) ? $reference : GeneralUtility::getFileAbsFileName($reference); + } +} diff --git a/Classes/ViewHelpers/Asset/CssViewHelper.php b/Classes/ViewHelpers/Asset/CssViewHelper.php new file mode 100644 index 0000000..2be64d5 --- /dev/null +++ b/Classes/ViewHelpers/Asset/CssViewHelper.php @@ -0,0 +1,129 @@ + + * + * .foo { color: black; } + * + * ``` + * + * @see https://docs.typo3.org/permalink/t3viewhelper:typo3-fluid-asset-css + */ +final class CssViewHelper extends AbstractTagBasedViewHelper +{ + /** + * This VH does not produce direct output, thus does not need to be wrapped in an escaping node + * + * @var bool + */ + protected $escapeOutput = false; + + /** + * Rendered children string is passed as CSS code, + * there is no point in HTML encoding anything from that. + * + * @var bool + */ + protected $escapeChildren = true; + + public function __construct( + private readonly AssetCollector $assetCollector, + ) { + parent::__construct(); + } + + public function initialize(): void + { + // Add a tag builder, that does not html encode values, because rendering with encoding happens in AssetRenderer + $this->setTagBuilder( + new class extends TagBuilder { + public function addAttribute($attributeName, $attributeValue, $escapeSpecialCharacters = false): void + { + parent::addAttribute($attributeName, $attributeValue, false); + } + } + ); + parent::initialize(); + } + + public function initializeArguments(): void + { + parent::initializeArguments(); + $this->registerArgument('disabled', 'bool', 'Define whether or not the described stylesheet should be loaded and applied to the document.'); + $this->registerArgument('csp', 'bool', 'Whether to collect a CSP hash value for this asset (default: true for external files, false for inline)', false, null); + $this->registerArgument('identifier', 'string', 'Use this identifier within templates to only inject your CSS once, even though it is added multiple times.', true); + $this->registerArgument('priority', 'boolean', 'Define whether the CSS should be included before other CSS. CSS will always be output in the tag.', false, false); + $this->registerArgument('inline', 'bool', 'Define whether or not the referenced file should be loaded as inline styles (Only to be used if \'href\' is set).', false, false); + } + + public function render(): string + { + $identifier = (string)$this->arguments['identifier']; + $attributes = $this->tag->getAttributes(); + + // boolean attributes shall output attr="attr" if set + if ($this->arguments['disabled'] ?? false) { + $attributes['disabled'] = 'disabled'; + } + + $file = $attributes['href'] ?? null; + unset($attributes['href']); + $isExternalFile = $file !== null && !($this->arguments['inline'] ?? false); + $useCsp = $this->resolveCspOption($isExternalFile); + $options = [ + 'priority' => $this->arguments['priority'], + 'csp' => $useCsp, + ]; + if ($file !== null) { + if ($this->arguments['inline'] ?? false) { + $content = @file_get_contents(GeneralUtility::getFileAbsFileName(trim($file))); + if ($content !== false) { + $this->assetCollector->addInlineStyleSheet($identifier, $content, $attributes, $options); + } + } else { + $this->assetCollector->addStyleSheet($identifier, $file, $attributes, $options); + } + } else { + $content = (string)$this->renderChildren(); + if ($content !== '') { + $this->assetCollector->addInlineStyleSheet($identifier, $content, $attributes, $options); + } + } + return ''; + } + + private function resolveCspOption(bool $defaultForStatic): bool + { + $csp = $this->arguments['csp']; + if ($csp !== null) { + return (bool)$csp; + } + // Default: true for external files (allows hash collection), false for inline + return $defaultForStatic; + } +} diff --git a/Classes/ViewHelpers/Asset/ModuleViewHelper.php b/Classes/ViewHelpers/Asset/ModuleViewHelper.php new file mode 100644 index 0000000..e15dc8c --- /dev/null +++ b/Classes/ViewHelpers/Asset/ModuleViewHelper.php @@ -0,0 +1,59 @@ + + * + * Details + * ======= + * + * In the AssetCollector, the "identifier" attribute is used as a unique identifier. Thus, if modules are added multiple + * times using the same module identifier, the asset will only be served once. + * + * @see https://docs.typo3.org/permalink/t3viewhelper:typo3-fluid-asset-module + */ +final class ModuleViewHelper extends AbstractViewHelper +{ + public function __construct( + private readonly AssetCollector $assetCollector, + ) {} + + public function initializeArguments(): void + { + parent::initializeArguments(); + $this->registerArgument('identifier', 'string', 'Bare module identifier like "@my/package/filename.js".', true); + } + + public function render(): string + { + $identifier = (string)$this->arguments['identifier']; + $this->assetCollector->addJavaScriptModule($identifier); + return ''; + } +} diff --git a/Classes/ViewHelpers/Asset/ScriptViewHelper.php b/Classes/ViewHelpers/Asset/ScriptViewHelper.php new file mode 100644 index 0000000..7126e25 --- /dev/null +++ b/Classes/ViewHelpers/Asset/ScriptViewHelper.php @@ -0,0 +1,133 @@ + + * + * alert('hello world'); + * + * ``` + * + * @see https://docs.typo3.org/permalink/t3viewhelper:typo3-fluid-asset-script + */ +final class ScriptViewHelper extends AbstractTagBasedViewHelper +{ + /** + * This VH does not produce direct output, thus does not need to be wrapped in an escaping node + * + * @var bool + */ + protected $escapeOutput = false; + + /** + * Rendered children string is passed as JavaScript code, + * there is no point in HTML encoding anything from that. + * + * @var bool + */ + protected $escapeChildren = false; + + public function __construct( + private readonly AssetCollector $assetCollector, + ) { + parent::__construct(); + } + + public function initialize(): void + { + // Add a tag builder, that does not html encode values, because rendering with encoding happens in AssetRenderer + $this->setTagBuilder( + new class extends TagBuilder { + public function addAttribute($attributeName, $attributeValue, $escapeSpecialCharacters = false): void + { + parent::addAttribute($attributeName, $attributeValue, false); + } + } + ); + parent::initialize(); + } + + public function initializeArguments(): void + { + parent::initializeArguments(); + $this->registerArgument('async', 'bool', 'Define that the script will be fetched in parallel to parsing and evaluation.'); + $this->registerArgument('defer', 'bool', 'Define that the script is meant to be executed after the document has been parsed.'); + $this->registerArgument('nomodule', 'bool', 'Define that the script should not be executed in browsers that support ES2015 modules.'); + $this->registerArgument('csp', 'bool', 'Whether to collect a CSP hash value for this asset (default: true for external files, false for inline)', false, null); + $this->registerArgument('identifier', 'string', 'Use this identifier within templates to only inject your JS once, even though it is added multiple times.', true); + $this->registerArgument('priority', 'boolean', 'Define whether the JavaScript should be put in the tag above-the-fold or somewhere in the body part.', false, false); + $this->registerArgument('inline', 'bool', 'Define whether or not the referenced file should be loaded as inline script (Only to be used if \'src\' is set).', false, false); + } + + public function render(): string + { + $identifier = (string)$this->arguments['identifier']; + $attributes = $this->tag->getAttributes(); + + // boolean attributes shall output attr="attr" if set + foreach (['async', 'defer', 'nomodule'] as $attribute) { + if ($this->arguments[$attribute] ?? false) { + $attributes[$attribute] = $attribute; + } + } + + $src = $attributes['src'] ?? null; + unset($attributes['src']); + $isExternalFile = $src !== null && !($this->arguments['inline'] ?? false); + $useCsp = $this->resolveCspOption($isExternalFile); + $options = [ + 'priority' => $this->arguments['priority'], + 'csp' => $useCsp, + ]; + if ($src !== null) { + if ($this->arguments['inline'] ?? false) { + $content = @file_get_contents(GeneralUtility::getFileAbsFileName(trim($src))); + if ($content !== false) { + $this->assetCollector->addInlineJavaScript($identifier, $content, $attributes, $options); + } + } else { + $this->assetCollector->addJavaScript($identifier, $src, $attributes, $options); + } + } else { + $content = (string)$this->renderChildren(); + if ($content !== '') { + $this->assetCollector->addInlineJavaScript($identifier, $content, $attributes, $options); + } + } + return ''; + } + + private function resolveCspOption(bool $defaultForStatic): bool + { + $csp = $this->arguments['csp']; + if ($csp !== null) { + return (bool)$csp; + } + // Default: true for external files (allows hash collection), false for inline + return $defaultForStatic; + } +} diff --git a/Classes/ViewHelpers/Asset/StyleAttrViewHelper.php b/Classes/ViewHelpers/Asset/StyleAttrViewHelper.php new file mode 100644 index 0000000..6fdde20 --- /dev/null +++ b/Classes/ViewHelpers/Asset/StyleAttrViewHelper.php @@ -0,0 +1,55 @@ +... + * ``` + * + * @see https://docs.typo3.org/permalink/t3viewhelper:typo3-fluid-asset-styleattr + */ +final class StyleAttrViewHelper extends AbstractViewHelper +{ + public function __construct(private readonly DirectiveHashCollection $directiveHashCollection) {} + + public function initializeArguments(): void + { + $this->registerArgument('value', 'string', 'The inline style value (e.g. "color: green; text-decoration: underline;")', true); + $this->registerArgument('csp', 'bool', 'Whether to collect a CSP hash for this style value', false, true); + } + + public function render(): string + { + $value = trim($this->arguments['value'] ?? $this->renderChildren()); + if ($this->arguments['csp'] ?? true) { + $this->directiveHashCollection->addInlineHash(Directive::StyleSrcAttr, $value); + } + return $value; + } +} diff --git a/Classes/ViewHelpers/Be/AbstractBackendViewHelper.php b/Classes/ViewHelpers/Be/AbstractBackendViewHelper.php new file mode 100644 index 0000000..1f42f27 --- /dev/null +++ b/Classes/ViewHelpers/Be/AbstractBackendViewHelper.php @@ -0,0 +1,72 @@ +renderingContext->getViewHelperVariableContainer(); + if ($viewHelperVariableContainer->exists(self::class, 'ModuleTemplate')) { + $moduleTemplate = $viewHelperVariableContainer->get(self::class, 'ModuleTemplate'); + } else { + $moduleTemplate = GeneralUtility::makeInstance(ModuleTemplate::class); + $viewHelperVariableContainer->add(self::class, 'ModuleTemplate', $moduleTemplate); + } + return $moduleTemplate; + } + + /** + * Gets instance of PageRenderer if exists or create a new one. + * Saves instance in viewHelperVariableContainer + */ + public function getPageRenderer(): PageRenderer + { + trigger_error( + 'AbstractBackendViewHelper::getPageRenderer() has been deprecated in TYPO3 v15.0 and will be removed in v16.0.', + E_USER_DEPRECATED + ); + $viewHelperVariableContainer = $this->renderingContext->getViewHelperVariableContainer(); + if ($viewHelperVariableContainer->exists(self::class, 'PageRenderer')) { + $pageRenderer = $viewHelperVariableContainer->get(self::class, 'PageRenderer'); + } else { + $pageRenderer = GeneralUtility::makeInstance(PageRenderer::class); + $viewHelperVariableContainer->add(self::class, 'PageRenderer', $pageRenderer); + } + return $pageRenderer; + } +} diff --git a/Classes/ViewHelpers/Be/InfoboxViewHelper.php b/Classes/ViewHelpers/Be/InfoboxViewHelper.php new file mode 100644 index 0000000..6f439ff --- /dev/null +++ b/Classes/ViewHelpers/Be/InfoboxViewHelper.php @@ -0,0 +1,101 @@ +your box content + * your box content + * ``` + * + * @see https://docs.typo3.org/permalink/t3viewhelper:typo3-fluid-be-infobox + */ +final class InfoboxViewHelper extends AbstractViewHelper +{ + /** + * As this ViewHelper renders HTML, the output must not be escaped. + * + * @var bool + */ + protected $escapeOutput = false; + + public function __construct( + private readonly IconFactory $iconFactory + ) {} + + public function initializeArguments(): void + { + $this->registerArgument('message', 'string', 'The message of the info box, if NULL tag content is used'); + $this->registerArgument('title', 'string', 'The title of the info box'); + $this->registerArgument('state', 'mixed', 'The state of the box, accepts ContextualFeedbackSeverity enum or integer value', false, ContextualFeedbackSeverity::NOTICE); + $this->registerArgument('iconName', 'string', 'Identifier of the icon as registered in the Icon Registry. NULL sets default icon'); + $this->registerArgument('disableIcon', 'bool', 'If set to TRUE, the icon is not rendered.', false, false); + } + + public function render(): string + { + $title = (string)$this->arguments['title']; + $message = (string)$this->renderChildren(); + $state = $this->arguments['state']; + + // The state argument accepts both a ContextualFeedbackSeverity enum and a raw integer value + if ($state instanceof ContextualFeedbackSeverity) { + $severity = $state; + } else { + $state = (int)$state; + $severity = ContextualFeedbackSeverity::from($state); + } + $disableIcon = $this->arguments['disableIcon']; + $icon = $this->arguments['iconName'] ?? $severity->getIconIdentifier(); + $iconTemplate = ''; + if (!$disableIcon) { + $iconTemplate = '' + . '
' + . '' + . $this->iconFactory->getIcon($icon, IconSize::SMALL)->render() + . '' + . '
'; + } + $titleTemplate = ''; + if ($title !== '') { + $titleTemplate = '
' . htmlspecialchars($title) . '
'; + } + return '
' + . $iconTemplate + . '
' + . $titleTemplate + . '
' . $message . '
' + . '
' + . '
'; + } + + /** + * Explicitly set argument name to be used as content. + */ + public function getContentArgumentName(): string + { + return 'message'; + } +} diff --git a/Classes/ViewHelpers/Be/LinkViewHelper.php b/Classes/ViewHelpers/Be/LinkViewHelper.php new file mode 100644 index 0000000..d5b4559 --- /dev/null +++ b/Classes/ViewHelpers/Be/LinkViewHelper.php @@ -0,0 +1,64 @@ +Go to web_ts + * ``` + * + * @see https://docs.typo3.org/permalink/t3viewhelper:typo3-fluid-be-link + */ +final class LinkViewHelper extends AbstractTagBasedViewHelper +{ + /** + * @var string + */ + protected $tagName = 'a'; + + public function __construct( + private readonly UriBuilder $uriBuilder + ) { + parent::__construct(); + } + + public function initializeArguments(): void + { + parent::initializeArguments(); + $this->registerArgument('route', 'string', 'The name of the route', true); + $this->registerArgument('parameters', 'array', 'An array of parameters', false, []); + $this->registerArgument('referenceType', 'string', 'The type of reference to be generated (one of the constants)', false, UriBuilder::ABSOLUTE_PATH); + } + + public function render(): string + { + $route = $this->arguments['route']; + $parameters = $this->arguments['parameters']; + $referenceType = $this->arguments['referenceType']; + $uri = $this->uriBuilder->buildUriFromRoute($route, $parameters, $referenceType); + $this->tag->addAttribute('href', (string)$uri); + $this->tag->setContent((string)$this->renderChildren()); + $this->tag->forceClosingTag(true); + return $this->tag->render(); + } +} diff --git a/Classes/ViewHelpers/Be/Menus/ActionMenuItemGroupViewHelper.php b/Classes/ViewHelpers/Be/Menus/ActionMenuItemGroupViewHelper.php new file mode 100644 index 0000000..42415ca --- /dev/null +++ b/Classes/ViewHelpers/Be/Menus/ActionMenuItemGroupViewHelper.php @@ -0,0 +1,68 @@ +` group. + * + * ``` + * + * + * + * + * + * ... + * + * + * ``` + * + * **NOTE**: This ViewHelper is experimental and tailored to be used only in extbase context. + * + * @see https://docs.typo3.org/permalink/t3viewhelper:typo3-fluid-be-menus-actionmenuitemgroup + */ +final class ActionMenuItemGroupViewHelper extends AbstractTagBasedViewHelper +{ + /** + * @var string + */ + protected $tagName = 'optgroup'; + + public function initializeArguments(): void + { + parent::initializeArguments(); + // @todo: deprecate + $this->registerArgument('defaultController', 'string', 'Unused'); + $this->registerArgument('label', 'string', 'The label of the option group', false, ''); + } + + public function render(): string + { + $this->tag->addAttribute('label', $this->arguments['label']); + $options = ''; + foreach ($this->viewHelperNode->getChildNodes() as $childNode) { + if ($childNode instanceof ViewHelperNode) { + $options .= $childNode->evaluate($this->renderingContext); + } + } + $this->tag->setContent($options); + return $this->tag->render(); + } +} diff --git a/Classes/ViewHelpers/Be/Menus/ActionMenuItemViewHelper.php b/Classes/ViewHelpers/Be/Menus/ActionMenuItemViewHelper.php new file mode 100644 index 0000000..690346b --- /dev/null +++ b/Classes/ViewHelpers/Be/Menus/ActionMenuItemViewHelper.php @@ -0,0 +1,112 @@ +` group. + * + * ``` + * + * + * + * + * + * ... + * + * + * ``` + * + * **Note:** This ViewHelper is experimental and tailored to be used only in extbase context. + * + * @see https://docs.typo3.org/permalink/t3viewhelper:typo3-fluid-be-menus-actionmenuitem + */ +final class ActionMenuItemViewHelper extends AbstractTagBasedViewHelper +{ + /** + * @var string + */ + protected $tagName = 'option'; + + public function initializeArguments(): void + { + parent::initializeArguments(); + $this->registerArgument('label', 'string', 'label of the option tag', true); + $this->registerArgument('controller', 'string', 'controller to be associated with this ActionMenuItem', true); + $this->registerArgument('action', 'string', 'the action to be associated with this ActionMenuItem', true); + $this->registerArgument('arguments', 'array', 'additional controller arguments to be passed to the action when this ActionMenuItem is selected', false, []); + } + + public function render(): string + { + $label = $this->arguments['label']; + $controller = $this->arguments['controller']; + $action = $this->arguments['action']; + $arguments = $this->arguments['arguments']; + + $uriBuilder = GeneralUtility::makeInstance(UriBuilder::class); + if (!$this->renderingContext->hasAttribute(ServerRequestInterface::class) + || !$this->renderingContext->getAttribute(ServerRequestInterface::class) instanceof RequestInterface) { + // Throw if not an extbase request + throw new \RuntimeException( + 'ViewHelper f:be.menus.actionMenuItem needs an extbase Request object to create URIs.', + 1639741792 + ); + } + /** @var RequestInterface $request */ + $request = $this->renderingContext->getAttribute(ServerRequestInterface::class); + $uriBuilder->setRequest($request); + + $uri = $uriBuilder->reset()->uriFor($action, $arguments, $controller); + $this->tag->addAttribute('value', $uri); + + if (!$this->tag->hasAttribute('selected')) { + $this->evaluateSelectItemState($controller, $action, $arguments); + } + + $this->tag->setContent(htmlspecialchars($label, ENT_QUOTES, '', true)); + return $this->tag->render(); + } + + private function evaluateSelectItemState(string $controller, string $action, array $arguments): void + { + /** @var RequestInterface $request */ + $request = $this->renderingContext->getAttribute(ServerRequestInterface::class); + $flatRequestArguments = ArrayUtility::flattenPlain( + array_merge([ + 'controller' => $request->getControllerName(), + 'action' => $request->getControllerActionName(), + ], $request->getArguments()) + ); + $flatViewHelperArguments = ArrayUtility::flattenPlain( + array_merge(['controller' => $controller, 'action' => $action], $arguments) + ); + if ( + ($this->arguments['selected'] ?? false) + || array_diff($flatRequestArguments, $flatViewHelperArguments) === [] + ) { + $this->tag->addAttribute('selected', 'selected'); + } + } +} diff --git a/Classes/ViewHelpers/Be/Menus/ActionMenuViewHelper.php b/Classes/ViewHelpers/Be/Menus/ActionMenuViewHelper.php new file mode 100644 index 0000000..25b650a --- /dev/null +++ b/Classes/ViewHelpers/Be/Menus/ActionMenuViewHelper.php @@ -0,0 +1,91 @@ + + * + * + * + * + * ... + * + * + * ``` + * + * **Note:** This ViewHelper is experimental and tailored to be used only in extbase context. + * + * @see https://docs.typo3.org/permalink/t3viewhelper:typo3-fluid-be-menus-actionmenu + */ +final class ActionMenuViewHelper extends AbstractTagBasedViewHelper +{ + /** + * @var string + */ + protected $tagName = 'select'; + + public function __construct( + private readonly PageRenderer $pageRenderer + ) { + parent::__construct(); + } + + public function initializeArguments(): void + { + parent::initializeArguments(); + $this->registerArgument('defaultController', 'string', 'The default controller to be used'); + } + + public function render(): string + { + $options = ''; + foreach ($this->viewHelperNode->getChildNodes() as $childNode) { + if ($childNode instanceof ViewHelperNode) { + $options .= $childNode->evaluate($this->renderingContext); + } + } + $this->tag->addAttributes([ + 'data-global-event' => 'change', + 'data-action-navigate' => '$value', + ]); + $this->tag->setContent($options); + $this->pageRenderer->loadJavaScriptModule('@typo3/backend/global-event-handler.js'); + return '
' . $this->tag->render() . '
'; + } + + /** + * @param string $argumentsName + * @param string $closureName + * @param string $initializationPhpCode + */ + public function compile($argumentsName, $closureName, &$initializationPhpCode, ViewHelperNode $node, TemplateCompiler $compiler): string + { + // @todo: replace with a true compiling method to make compilable! + $compiler->disable(); + return ''; + } +} diff --git a/Classes/ViewHelpers/Be/PageInfoViewHelper.php b/Classes/ViewHelpers/Be/PageInfoViewHelper.php new file mode 100644 index 0000000..3770eb8 --- /dev/null +++ b/Classes/ViewHelpers/Be/PageInfoViewHelper.php @@ -0,0 +1,80 @@ + + * ``` + * + * **Note:** This ViewHelper is experimental! + * + * @see https://docs.typo3.org/permalink/t3viewhelper:typo3-fluid-be-pageinfo + * @todo: Candidate to deprecate? The page info is typically displayed in doc header, done by ModuleTemplate in controllers. + */ +final class PageInfoViewHelper extends AbstractViewHelper +{ + /** + * This ViewHelper renders HTML, thus output must not be escaped + * + * @var bool + */ + protected $escapeOutput = false; + + public function __construct( + private readonly IconFactory $iconFactory + ) {} + + public function render(): string + { + $id = 0; + if ($this->renderingContext->hasAttribute(ServerRequestInterface::class)) { + $request = $this->renderingContext->getAttribute(ServerRequestInterface::class); + $id = $request->getParsedBody()['id'] ?? $request->getQueryParams()['id'] ?? 0; + } + $pageRecord = BackendUtility::readPageAccess($id, $GLOBALS['BE_USER']->getPagePermsClause(Permission::PAGE_SHOW)); + // Add icon with context menu, etc: + if (is_array($pageRecord) && ($pageRecord['uid'] ?? false)) { + // If there IS a real page + $altText = BackendUtility::getRecordIconAltText($pageRecord, 'pages'); + $theIcon = '' . $this->iconFactory->getIconForRecord('pages', $pageRecord, IconSize::SMALL)->render() . ''; + // Make Icon: + $theIcon = BackendUtility::wrapClickMenuOnIcon($theIcon, 'pages', $pageRecord['uid']); + + // Setting icon with context menu + uid + $theIcon .= ' [PID: ' . $pageRecord['uid'] . ']'; + } else { + // On root-level of page tree + // Make Icon + $theIcon = '' . $this->iconFactory->getIcon('apps-pagetree-page-domain', IconSize::SMALL)->render() . ''; + if ($GLOBALS['BE_USER']->isAdmin()) { + $theIcon = BackendUtility::wrapClickMenuOnIcon($theIcon, 'pages'); + } + } + return $theIcon; + } +} diff --git a/Classes/ViewHelpers/Be/PagePathViewHelper.php b/Classes/ViewHelpers/Be/PagePathViewHelper.php new file mode 100644 index 0000000..4b7e1ba --- /dev/null +++ b/Classes/ViewHelpers/Be/PagePathViewHelper.php @@ -0,0 +1,88 @@ + + * ``` + * + * **Note:** This ViewHelper is experimental! + * + * @see https://docs.typo3.org/permalink/t3viewhelper:typo3-fluid-be-pagepath + * @deprecated since TYPO3 v15.0, will be removed in TYPO3 v16.0. + */ +final class PagePathViewHelper extends AbstractViewHelper implements ViewHelperNodeInitializedEventInterface +{ + /** + * This ViewHelper renders HTML, thus output must not be escaped + * + * @var bool + */ + protected $escapeOutput = false; + + public function render(): string + { + $id = 0; + if ($this->renderingContext->hasAttribute(ServerRequestInterface::class)) { + $request = $this->renderingContext->getAttribute(ServerRequestInterface::class); + $id = $request->getParsedBody()['id'] ?? $request->getQueryParams()['id'] ?? 0; + } + $pageRecord = BackendUtility::readPageAccess($id, $GLOBALS['BE_USER']->getPagePermsClause(Permission::PAGE_SHOW)); + // Is this a real page + if ($pageRecord['_thePathFull'] ?? false) { + $title = (string)$pageRecord['_thePathFull']; + } else { + $title = (string)$GLOBALS['TYPO3_CONF_VARS']['SYS']['sitename']; + } + // Setting the path of the page + $pagePath = htmlspecialchars(self::getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.path')) . ': '; + $croppedTitle = BackendUtility::cropToTitleLength($title, null, true); + if ($croppedTitle !== $title) { + $pagePath .= '' . htmlspecialchars($croppedTitle) . ''; + } else { + $pagePath .= htmlspecialchars($title); + } + $pagePath .= ''; + return $pagePath; + } + + public static function nodeInitializedEvent(ViewHelperNode $node, array $arguments, ParsingState $parsingState): void + { + trigger_error( + ' has been deprecated in TYPO3 v15.0 and will be removed in v16.0.', + E_USER_DEPRECATED + ); + } + + private static function getLanguageService(): LanguageService + { + return $GLOBALS['LANG']; + } +} diff --git a/Classes/ViewHelpers/Be/PageRendererViewHelper.php b/Classes/ViewHelpers/Be/PageRendererViewHelper.php new file mode 100644 index 0000000..766c5f7 --- /dev/null +++ b/Classes/ViewHelpers/Be/PageRendererViewHelper.php @@ -0,0 +1,114 @@ + + * ``` + * @see https://docs.typo3.org/permalink/t3viewhelper:typo3-fluid-be-pagerenderer + */ +final class PageRendererViewHelper extends AbstractViewHelper +{ + public function __construct( + private readonly PageRenderer $pageRenderer + ) {} + + public function initializeArguments(): void + { + $this->registerArgument('pageTitle', 'string', 'title tag of the module. Not required by default, as BE modules are shown in a frame', false, ''); + $this->registerArgument('includeCssFiles', 'array', 'List of custom CSS file to be loaded'); + $this->registerArgument('includeJsFiles', 'array', 'List of custom JavaScript file to be loaded'); + $this->registerArgument('addJsInlineLabels', 'array', 'Custom labels to add to JavaScript inline labels'); + $this->registerArgument('includeJavaScriptModules', 'array', 'List of JavaScript modules to be loaded'); + $this->registerArgument('addInlineSettings', 'array', 'Adds Javascript Inline Setting'); + } + + public function render(): string + { + $pageTitle = $this->arguments['pageTitle']; + $includeCssFiles = $this->arguments['includeCssFiles']; + $includeJsFiles = $this->arguments['includeJsFiles']; + $addJsInlineLabels = $this->arguments['addJsInlineLabels']; + $includeJavaScriptModules = $this->arguments['includeJavaScriptModules']; + $addInlineSettings = $this->arguments['addInlineSettings']; + if ($pageTitle) { + $this->pageRenderer->setTitle($pageTitle); + } + // Include custom CSS and JS files + if (is_array($includeCssFiles)) { + foreach ($includeCssFiles as $addCssFile) { + $this->pageRenderer->addCssFile($addCssFile); + } + } + if (is_array($includeJsFiles)) { + foreach ($includeJsFiles as $addJsFile) { + $this->pageRenderer->addJsFile($addJsFile); + } + } + if (is_array($includeJavaScriptModules)) { + foreach ($includeJavaScriptModules as $addJavaScriptModule) { + $this->pageRenderer->loadJavaScriptModule($addJavaScriptModule); + } + } + if (is_array($addInlineSettings)) { + $this->pageRenderer->addInlineSettingArray('', $addInlineSettings); + } + // Add inline language labels + if (is_array($addJsInlineLabels) && count($addJsInlineLabels) > 0) { + if ($this->renderingContext->hasAttribute(ServerRequestInterface::class) + && $this->renderingContext->getAttribute(ServerRequestInterface::class) instanceof RequestInterface) { + // Extbase request resolves extension key and allows overriding labels using TypoScript configuration. + $extensionKey = $this->renderingContext->getAttribute(ServerRequestInterface::class)->getControllerExtensionKey(); + foreach ($addJsInlineLabels as $key) { + $label = LocalizationUtility::translate($key, $extensionKey); + $this->pageRenderer->addInlineLanguageLabel($key, $label); + } + } else { + // No extbase request, labels should follow "LLL:EXT:some_ext/Resources/Private/someFile.xlf:key" + // syntax, and are not overridden by TypoScript extbase module / plugin configuration. + foreach ($addJsInlineLabels as &$labelKey) { + $labelKey = self::getLanguageService()->sL($labelKey); + } + $this->pageRenderer->addInlineLanguageLabelArray($addJsInlineLabels); + } + } + return ''; + } + + private static function getLanguageService(): LanguageService + { + return $GLOBALS['LANG']; + } +} diff --git a/Classes/ViewHelpers/Be/Security/IfAuthenticatedViewHelper.php b/Classes/ViewHelpers/Be/Security/IfAuthenticatedViewHelper.php new file mode 100644 index 0000000..e91774d --- /dev/null +++ b/Classes/ViewHelpers/Be/Security/IfAuthenticatedViewHelper.php @@ -0,0 +1,46 @@ + + * + * This is being shown in case you have access. + * + * + * This is being displayed in case you do not have access. + * + * + * ``` + * + * @see https://docs.typo3.org/permalink/t3viewhelper:typo3-fluid-be-security-ifauthenticated + */ +final class IfAuthenticatedViewHelper extends AbstractConditionViewHelper +{ + public static function verdict(array $arguments, RenderingContextInterface $renderingContext): bool + { + return isset($GLOBALS['BE_USER']) && $GLOBALS['BE_USER']->user['uid'] > 0; + } +} diff --git a/Classes/ViewHelpers/Be/Security/IfHasRoleViewHelper.php b/Classes/ViewHelpers/Be/Security/IfHasRoleViewHelper.php new file mode 100644 index 0000000..063d424 --- /dev/null +++ b/Classes/ViewHelpers/Be/Security/IfHasRoleViewHelper.php @@ -0,0 +1,74 @@ + + * + * This is being shown in case you have the role. + * + * + * This is being displayed in case you do not have the role. + * + * + * ``` + * + * @see https://docs.typo3.org/permalink/t3viewhelper:typo3-fluid-be-security-ifhasrole + */ +final class IfHasRoleViewHelper extends AbstractConditionViewHelper +{ + /** + * Initializes the "role" argument. + * Renders child if the current logged in BE user belongs to the specified role (aka usergroup) + * otherwise renders child. + */ + public function initializeArguments(): void + { + parent::initializeArguments(); + $this->registerArgument('role', 'string', 'The usergroup (either the usergroup uid or its title).'); + } + + public static function verdict(array $arguments, RenderingContextInterface $renderingContext): bool + { + $role = $arguments['role']; + if (!is_array($GLOBALS['BE_USER']->userGroups) || $arguments['role'] === null) { + return false; + } + if (is_numeric($role)) { + foreach ($GLOBALS['BE_USER']->userGroups as $userGroup) { + if ((int)$userGroup['uid'] === (int)$role) { + return true; + } + } + } else { + foreach ($GLOBALS['BE_USER']->userGroups as $userGroup) { + if ($userGroup['title'] === $role) { + return true; + } + } + } + return false; + } +} diff --git a/Classes/ViewHelpers/Be/TableListViewHelper.php b/Classes/ViewHelpers/Be/TableListViewHelper.php new file mode 100644 index 0000000..9fad1a7 --- /dev/null +++ b/Classes/ViewHelpers/Be/TableListViewHelper.php @@ -0,0 +1,160 @@ + + * ``` + * + * **Note:** This feature is experimental! + * + * @see https://docs.typo3.org/permalink/t3viewhelper:typo3-fluid-be-tablelist + */ +final class TableListViewHelper extends AbstractViewHelper +{ + /** + * As this ViewHelper renders HTML, the output must not be escaped. + * + * @var bool + */ + protected $escapeOutput = false; + + public function __construct( + private readonly ConfigurationManagerInterface $configurationManager, + private readonly PageRenderer $pageRenderer, + ) {} + + public function initializeArguments(): void + { + parent::initializeArguments(); + $this->registerArgument('tableName', 'string', 'name of the database table', true); + $this->registerArgument('fieldList', 'array', 'list of fields to be displayed. If empty, only the title column (configured in $TCA[$tableName][\'ctrl\'][\'title\']) is shown', false, []); + $this->registerArgument('storagePid', 'int', 'by default, records are fetched from the storage PID configured in persistence.storagePid. With this argument, the storage PID can be overwritten'); + $this->registerArgument('levels', 'int', 'corresponds to the level selector of the TYPO3 records module. By default only records from the current storagePid are fetched', false, 0); + $this->registerArgument('filter', 'string', 'corresponds to the "Search String" textbox of the TYPO3 records module. If not empty, only records matching the string will be fetched', false, ''); + $this->registerArgument('recordsPerPage', 'int', 'amount of records to be displayed at once. Defaults to 100', false, 0); + $this->registerArgument('sortField', 'string', 'table field to sort the results by', false, ''); + $this->registerArgument('sortDescending', 'bool', 'if TRUE records will be sorted in descending order', false, false); + $this->registerArgument('readOnly', 'bool', 'if TRUE, the edit icons won\'t be shown. Otherwise edit icons will be shown, if the current BE user has edit rights for the specified table!', false, false); + $this->registerArgument('enableClickMenu', 'bool', 'enables context menu', false, true); + $this->registerArgument('enableControlPanels', 'bool', 'enables control panels', false, false); + $this->registerArgument('clickTitleMode', 'string', 'one of "edit", "show" (only pages, tt_content), "info', false, ''); + } + + /** + * Renders a record list as known from the TYPO3 records module + * Note: This feature is experimental! + * + * @see DatabaseRecordList + */ + public function render(): string + { + $tableName = $this->arguments['tableName']; + $fieldList = $this->arguments['fieldList']; + $storagePid = $this->arguments['storagePid']; + $levels = $this->arguments['levels']; + $filter = $this->arguments['filter']; + $recordsPerPage = $this->arguments['recordsPerPage']; + $sortField = $this->arguments['sortField']; + $sortDescending = $this->arguments['sortDescending']; + $readOnly = $this->arguments['readOnly']; + $enableClickMenu = $this->arguments['enableClickMenu']; + $clickTitleMode = $this->arguments['clickTitleMode']; + $enableControlPanels = $this->arguments['enableControlPanels']; + + $backendUser = $this->getBackendUser(); + if (!$this->renderingContext->hasAttribute(ServerRequestInterface::class)) { + // All views in backend have at least ServerRequestInterface. Should be fine + // to assume having a request here, the early return is just sanitation. + return ''; + } + $request = $this->renderingContext->getAttribute(ServerRequestInterface::class); + + $this->pageRenderer->loadJavaScriptModule('@typo3/backend/recordlist.js'); + $this->pageRenderer->loadJavaScriptModule('@typo3/backend/record-download-button.js'); + $this->pageRenderer->loadJavaScriptModule('@typo3/backend/action-dispatcher.js'); + $this->pageRenderer->loadJavaScriptModule('@typo3/backend/page-wizard/new-page-wizard-button.js'); + if ($enableControlPanels === true) { + $this->pageRenderer->loadJavaScriptModule('@typo3/backend/multi-record-selection.js'); + $this->pageRenderer->loadJavaScriptModule('@typo3/backend/multi-record-selection-delete-action.js'); + $this->pageRenderer->loadJavaScriptModule('@typo3/backend/context-menu.js'); + } + + $pageId = (int)($request->getParsedBody()['id'] ?? $request->getQueryParams()['id'] ?? 0); + $pointer = (int)($request->getParsedBody()['pointer'] ?? $request->getQueryParams()['pointer'] ?? 0); + $pageInfo = BackendUtility::readPageAccess($pageId, $backendUser->getPagePermsClause(Permission::PAGE_SHOW)) ?: []; + $existingModuleData = $backendUser->getModuleData('records'); + $moduleData = new ModuleData('records', is_array($existingModuleData) ? $existingModuleData : []); + + $dbList = GeneralUtility::makeInstance(DatabaseRecordList::class); + $dbList->setRequest($request->withoutAttribute('pageContext')); + $dbList->setModuleData($moduleData); + $dbList->pageRow = $pageInfo; + if ($readOnly) { + $dbList->setIsEditable(false); + } else { + $dbList->calcPerms = new Permission($backendUser->calcPerms($pageInfo)); + } + $dbList->disableSingleTableView = true; + $dbList->clickTitleMode = $clickTitleMode; + $dbList->clickMenuEnabled = $enableClickMenu; + if ($storagePid === null) { + $frameworkConfiguration = $this->configurationManager->getConfiguration(ConfigurationManagerInterface::CONFIGURATION_TYPE_FRAMEWORK); + $storagePid = $frameworkConfiguration['persistence']['storagePid']; + } + $dbList->start($storagePid, $tableName, $pointer, $filter, $levels, $recordsPerPage); + // Column selector is disabled since fields are defined by the "fieldList" argument + $dbList->displayColumnSelector = false; + $dbList->setFields = [$tableName => $fieldList]; + $dbList->noControlPanels = !$enableControlPanels; + $dbList->sortField = $sortField; + $dbList->sortRev = $sortDescending; + return $dbList->generateList(); + } + + private function getBackendUser(): BackendUserAuthentication + { + return $GLOBALS['BE_USER']; + } +} diff --git a/Classes/ViewHelpers/Be/UriViewHelper.php b/Classes/ViewHelpers/Be/UriViewHelper.php new file mode 100644 index 0000000..6fe3840 --- /dev/null +++ b/Classes/ViewHelpers/Be/UriViewHelper.php @@ -0,0 +1,60 @@ + + * ``` + * + * @see https://docs.typo3.org/permalink/t3viewhelper:typo3-fluid-be-uri + */ +final class UriViewHelper extends AbstractViewHelper +{ + public function __construct( + private readonly UriBuilder $uriBuilder + ) {} + + public function initializeArguments(): void + { + parent::initializeArguments(); + $this->registerArgument('route', 'string', 'The name of the route', true); + $this->registerArgument('parameters', 'array', 'An array of parameters', false, []); + $this->registerArgument( + 'referenceType', + 'string', + 'The type of reference to be generated (one of the constants)', + false, + UriBuilder::ABSOLUTE_PATH + ); + } + + public function render(): string + { + $route = $this->arguments['route']; + $parameters = $this->arguments['parameters']; + $referenceType = $this->arguments['referenceType']; + $uri = $this->uriBuilder->buildUriFromRoute($route, $parameters, $referenceType); + return (string)$uri; + } +} diff --git a/Classes/ViewHelpers/CObjectViewHelper.php b/Classes/ViewHelpers/CObjectViewHelper.php new file mode 100644 index 0000000..7fe0f2d --- /dev/null +++ b/Classes/ViewHelpers/CObjectViewHelper.php @@ -0,0 +1,148 @@ + + * ``` + * + * **Note:** You have to ensure proper escaping (`htmlspecialchars`/`intval`/etc.) on your own! + * + * @see https://docs.typo3.org/permalink/t3viewhelper:typo3-fluid-cobject + */ +final class CObjectViewHelper extends AbstractViewHelper +{ + /** + * Disable escaping of child nodes' output + * + * @var bool + */ + protected $escapeChildren = false; + + /** + * Disable escaping of this node's output + * + * @var bool + */ + protected $escapeOutput = false; + + public function __construct( + private readonly TimeTracker $timeTracker, + private readonly ConfigurationManagerInterface $configurationManager, + ) {} + + public function initializeArguments(): void + { + $this->registerArgument('data', 'mixed', 'the data to be used for rendering the cObject. Can be an object, array or string. If this argument is not set, child nodes will be used'); + $this->registerArgument('typoscriptObjectPath', 'string', 'the TypoScript setup path of the TypoScript object to render', true); + $this->registerArgument('currentValueKey', 'string', 'currentValueKey'); + $this->registerArgument('table', 'string', 'the table name associated with "data" argument. Typically tt_content or one of your custom tables. This argument should be set if rendering a FILES cObject where file references are used, or if the data argument is a database record.', false, ''); + } + + /** + * Renders the TypoScript object in the given TypoScript setup path. + */ + public function render(): string + { + $data = $this->renderChildren() ?? []; + $typoscriptObjectPath = (string)$this->arguments['typoscriptObjectPath']; + $currentValueKey = $this->arguments['currentValueKey']; + $table = $this->arguments['table']; + if (!$this->renderingContext->hasAttribute(ServerRequestInterface::class)) { + throw new \RuntimeException('Required request not found in RenderingContext', 1724243608); + } + $request = $this->renderingContext->getAttribute(ServerRequestInterface::class); + $contentObjectRenderer = GeneralUtility::makeInstance(ContentObjectRenderer::class); + $contentObjectRenderer->setRequest($request); + $parent = $request->getAttribute('currentContentObject'); + if ($parent instanceof ContentObjectRenderer) { + $contentObjectRenderer->setParent($parent->data, $parent->currentRecord); + } + $currentValue = null; + if (is_object($data)) { + $data = $data instanceof RecordInterface ? ($data->getRawRecord()?->toArray(true) ?? $data->toArray()) : ObjectAccess::getGettableProperties($data); + } elseif (is_string($data) || is_numeric($data)) { + $currentValue = (string)$data; + $data = [$data]; + } + $contentObjectRenderer->start($data, $table); + if ($currentValue !== null) { + $contentObjectRenderer->setCurrentVal($currentValue); + } elseif ($currentValueKey !== null && isset($data[$currentValueKey])) { + $contentObjectRenderer->setCurrentVal($data[$currentValueKey]); + } + $pathSegments = GeneralUtility::trimExplode('.', $typoscriptObjectPath); + $lastSegment = (string)array_pop($pathSegments); + $setup = $this->configurationManager->getConfiguration(ConfigurationManagerInterface::CONFIGURATION_TYPE_FULL_TYPOSCRIPT); + foreach ($pathSegments as $segment) { + if (!array_key_exists($segment . '.', $setup)) { + throw new InvalidArgumentValueException( + 'TypoScript object path "' . $typoscriptObjectPath . '" does not exist', + 1253191023 + ); + } + $setup = $setup[$segment . '.']; + } + if (!isset($setup[$lastSegment])) { + throw new InvalidArgumentValueException( + 'No Content Object definition found at TypoScript object path "' . $typoscriptObjectPath . '"', + 1540246570 + ); + } + return $this->renderContentObject($contentObjectRenderer, $setup, $typoscriptObjectPath, $lastSegment); + } + + /** + * Renders single content object and increases time tracker stack pointer + */ + private function renderContentObject(ContentObjectRenderer $contentObjectRenderer, array $setup, string $typoscriptObjectPath, string $lastSegment): string + { + if ($this->timeTracker->LR) { + $this->timeTracker->push('/f:cObject/', '<' . $typoscriptObjectPath); + } + $this->timeTracker->incStackPointer(); + $content = $contentObjectRenderer->cObjGetSingle($setup[$lastSegment], $setup[$lastSegment . '.'] ?? [], $typoscriptObjectPath); + $this->timeTracker->decStackPointer(); + if ($this->timeTracker->LR) { + $this->timeTracker->pull($content); + } + return $content; + } + + /** + * Explicitly set argument name to be used as content. + */ + public function getContentArgumentName(): string + { + return 'data'; + } +} diff --git a/Classes/ViewHelpers/DebugViewHelper.php b/Classes/ViewHelpers/DebugViewHelper.php new file mode 100644 index 0000000..11e70b9 --- /dev/null +++ b/Classes/ViewHelpers/DebugViewHelper.php @@ -0,0 +1,86 @@ +{blogs} + * + * ``` + * + * @see https://docs.typo3.org/permalink/t3viewhelper:typo3-fluid-debug + */ +final class DebugViewHelper extends AbstractViewHelper +{ + /** + * This prevents double escaping as the output is encoded in DebuggerUtility::var_dump + * + * @var bool + */ + protected $escapeChildren = false; + + /** + * Output of this viewhelper is already escaped + * + * @var bool + */ + protected $escapeOutput = false; + + public function initializeArguments(): void + { + $this->registerArgument('title', 'string', 'optional custom title for the debug output'); + $this->registerArgument('maxDepth', 'int', 'Sets the max recursion depth of the dump (defaults to 8). De- or increase the number according to your needs and memory limit.', false, 8); + $this->registerArgument('plainText', 'bool', 'If TRUE, the dump is in plain text, if FALSE the debug output is in HTML format.', false, false); + $this->registerArgument('ansiColors', 'bool', 'If TRUE, ANSI color codes is added to the plaintext output, if FALSE (default) the plaintext debug output not colored.', false, false); + $this->registerArgument('inline', 'bool', 'if TRUE, the dump is rendered at the position of the tag. If FALSE (default), the dump is displayed at the top of the page.', false, false); + $this->registerArgument('blacklistedClassNames', 'array', 'An array of class names (RegEx) to be filtered. Default is an array of some common class names.'); + $this->registerArgument('blacklistedPropertyNames', 'array', 'An array of property names and/or array keys (RegEx) to be filtered. Default is an array of some common property names.'); + } + + /** + * A wrapper for \TYPO3\CMS\Extbase\Utility\DebuggerUtility::var_dump(). + */ + public function render(): string + { + return DebuggerUtility::var_dump( + $this->renderChildren(), + is_scalar($this->arguments['title']) ? (string)$this->arguments['title'] : null, + (int)$this->arguments['maxDepth'], + (bool)$this->arguments['plainText'], + (bool)$this->arguments['ansiColors'], + (bool)$this->arguments['inline'], + is_array($this->arguments['blacklistedClassNames']) ? $this->arguments['blacklistedClassNames'] : null, + is_array($this->arguments['blacklistedPropertyNames']) ? $this->arguments['blacklistedPropertyNames'] : null + ); + } +} diff --git a/Classes/ViewHelpers/FeatureViewHelper.php b/Classes/ViewHelpers/FeatureViewHelper.php new file mode 100644 index 0000000..4586527 --- /dev/null +++ b/Classes/ViewHelpers/FeatureViewHelper.php @@ -0,0 +1,54 @@ + + * + * Flag is enabled + * + * + * Flag is undefined or not enabled + * + * + * ``` + * + * @see https://docs.typo3.org/permalink/t3viewhelper:typo3-fluid-feature + */ +final class FeatureViewHelper extends AbstractConditionViewHelper +{ + public function initializeArguments(): void + { + parent::initializeArguments(); + $this->registerArgument('name', 'string', 'name of the feature flag that should be checked', true); + } + + public static function verdict(array $arguments, RenderingContextInterface $renderingContext): bool + { + return GeneralUtility::makeInstance(Features::class)->isFeatureEnabled($arguments['name']); + } +} diff --git a/Classes/ViewHelpers/FlashMessagesViewHelper.php b/Classes/ViewHelpers/FlashMessagesViewHelper.php new file mode 100644 index 0000000..c305342 --- /dev/null +++ b/Classes/ViewHelpers/FlashMessagesViewHelper.php @@ -0,0 +1,113 @@ + + * + * + *
+ * + *
{flashMessage.code}
+ *
{flashMessage.message}
+ *
+ *
+ *
+ * ``` + * + * @see https://docs.typo3.org/permalink/t3viewhelper:typo3-fluid-flashmessages + */ +final class FlashMessagesViewHelper extends AbstractViewHelper +{ + /** + * ViewHelper outputs HTML therefore output escaping has to be disabled + * + * @var bool + */ + protected $escapeOutput = false; + + public function __construct( + private readonly FlashMessageService $flashMessageService, + private readonly FlashMessageRendererResolver $flashMessageRendererResolver, + private readonly ExtensionService $extensionService + ) {} + + public function initializeArguments(): void + { + $this->registerArgument('queueIdentifier', 'string', 'Flash-message queue to use'); + $this->registerArgument('as', 'string', 'The name of the current flashMessage variable for rendering inside'); + } + + /** + * Renders FlashMessages and flushes the FlashMessage queue + * + * Note: This does not disable the current page cache in order to prevent FlashMessage output + * from being cached. + * In case of conditional flash message rendering, caching must be disabled + * (e.g. for a controller action). + * Custom caching using the Caching Framework can be used in this case. + */ + public function render(): string + { + $as = $this->arguments['as']; + $queueIdentifier = $this->arguments['queueIdentifier']; + if ($queueIdentifier === null) { + if (!$this->renderingContext->hasAttribute(ServerRequestInterface::class) + || !$this->renderingContext->getAttribute(ServerRequestInterface::class) instanceof RequestInterface + ) { + // Throw if not an extbase request + throw new \RuntimeException( + 'ViewHelper f:flashMessages needs an extbase Request object to resolve the Queue identifier magically.' + . ' When not in extbase context, set attribute "queueIdentifier".', + 1639821269 + ); + } + $request = $this->renderingContext->getAttribute(ServerRequestInterface::class); + $pluginNamespace = $this->extensionService->getPluginNamespace($request->getControllerExtensionName(), $request->getPluginName()); + $queueIdentifier = 'extbase.flashmessages.' . $pluginNamespace; + } + $flashMessageQueue = $this->flashMessageService->getMessageQueueByIdentifier($queueIdentifier); + $flashMessages = $flashMessageQueue->getAllMessagesAndFlush(); + if (count($flashMessages) === 0) { + return ''; + } + if ($as === null) { + return $this->flashMessageRendererResolver->resolve()->render($flashMessages); + } + $variableProvider = new ScopedVariableProvider($this->renderingContext->getVariableProvider(), new StandardVariableProvider([$as => $flashMessages])); + $this->renderingContext->setVariableProvider($variableProvider); + $content = (string)$this->renderChildren(); + $this->renderingContext->setVariableProvider($variableProvider->getGlobalVariableProvider()); + return $content; + } +} diff --git a/Classes/ViewHelpers/Form/AbstractFormFieldViewHelper.php b/Classes/ViewHelpers/Form/AbstractFormFieldViewHelper.php new file mode 100644 index 0000000..267d907 --- /dev/null +++ b/Classes/ViewHelpers/Form/AbstractFormFieldViewHelper.php @@ -0,0 +1,404 @@ +configurationManager = $configurationManager; + } + + public function initializeArguments(): void + { + parent::initializeArguments(); + $this->registerArgument('name', 'string', 'Name of input tag'); + $this->registerArgument('value', 'mixed', 'Value of input tag'); + $this->registerArgument('property', 'string', 'Name of Object Property. If used in conjunction with , the "name" property will be ignored, while "value" can be used to specify a default field value instead of the object property value.'); + } + + /** + * Getting the current configuration for respectSubmittedDataValue. + */ + public function getRespectSubmittedDataValue(): bool + { + return $this->respectSubmittedDataValue; + } + + /** + * Define respectSubmittedDataValue to enable or disable the usage of the submitted values in the viewhelper. + */ + public function setRespectSubmittedDataValue(bool $respectSubmittedDataValue): void + { + $this->respectSubmittedDataValue = $respectSubmittedDataValue; + } + + /** + * Get the name of this form element. + * Either returns arguments['name'], or the correct name for Object Access. + * In case property is something like bla.blubb (hierarchical), then [bla][blubb] is generated. + */ + protected function getName(): string + { + $name = $this->getNameWithoutPrefix(); + return $this->prefixFieldName($name); + } + + /** + * Shortcut for retrieving the request from the controller context + * + * @return RequestInterface The extbase (!) request. All these VH's are extbase-only. + */ + protected function getRequest(): RequestInterface + { + if (!$this->renderingContext->hasAttribute(ServerRequestInterface::class) + || !$this->renderingContext->getAttribute(ServerRequestInterface::class) instanceof RequestInterface + ) { + throw new \RuntimeException( + 'Form ViewHelpers are Extbase specific and need an Extbase Request to work', + 1663617170 + ); + } + return $this->renderingContext->getAttribute(ServerRequestInterface::class); + } + + /** + * Get the name of this form element, without prefix. + */ + protected function getNameWithoutPrefix(): string + { + if ($this->isObjectAccessorMode()) { + $formObjectName = $this->renderingContext->getViewHelperVariableContainer()->get( + FormViewHelper::class, + 'formObjectName' + ); + if (!empty($formObjectName)) { + $propertySegments = explode('.', (string)($this->arguments['property'] ?? '')); + $propertyPath = ''; + foreach ($propertySegments as $segment) { + $propertyPath .= '[' . $segment . ']'; + } + $name = $formObjectName . $propertyPath; + } else { + $name = $this->arguments['property'] ?? ''; + } + } else { + $name = $this->arguments['name'] ?? ''; + } + if ($this->hasArgument('value') + && is_object($this->arguments['value']) + && !$this->persistenceManager->isNewObject($this->arguments['value']) + ) { + $name .= '[__identity]'; + } + return (string)$name; + } + + /** + * Returns the current value of this Form ViewHelper and converts it to an identifier string in case it's an object + * The value is determined as follows: + * * If property mapping errors occurred and the form is re-displayed, the *last submitted* value is returned + * * If a "value" attribute was specified, this value is used (preferring an "override" from integrators) + * * Else the bound property value is returned (only in objectAccessor-mode) + * + * Note: This method should *not* be used for form elements that must not change the value attribute, e.g. (radio) buttons and checkboxes. + * + * @return mixed Value + */ + protected function getValueAttribute() + { + $value = null; + + if ($this->respectSubmittedDataValue) { + $value = $this->getValueFromSubmittedFormData($value); + } elseif ($this->hasArgument('value')) { + $value = $this->arguments['value']; + } elseif ($this->isObjectAccessorMode()) { + $value = $this->getPropertyValue(); + } + + $value = $this->convertToPlainValue($value); + return $value; + } + + /** + * If property mapping errors occurred and the form is re-displayed, the *last submitted* value is returned by this + * method. + * + * Note: + * This method should *not* be used for form elements that must not change the value attribute, e.g. (radio) + * buttons and checkboxes. The default behaviour is not to use this method. You need to set + * respectSubmittedDataValue to TRUE to enable the form data handling for the viewhelper. + * + * @param mixed $value + * @return mixed Value + */ + protected function getValueFromSubmittedFormData($value) + { + $submittedFormData = null; + if ($this->hasMappingErrorOccurred()) { + $submittedFormData = $this->getLastSubmittedFormData(); + } + if ($submittedFormData !== null) { + $value = $submittedFormData; + } elseif ($this->hasArgument('value')) { + $value = $this->arguments['value']; + } elseif ($this->isObjectAccessorMode()) { + $value = $this->getPropertyValue(); + } + + return $value; + } + + /** + * Converts an arbitrary value to a plain value + * + * @param mixed $value The value to convert + * @return mixed + */ + protected function convertToPlainValue($value) + { + if (is_object($value)) { + if ($value instanceof DomainObjectInterface && $value->getUid() !== null) { + // We prefer to use the `getUid()` method because this returns the properly overlaid identifier (defaultLanguageRecordUid). + // Otherwise, an identifier would contain '[defaultLanguageRecordUid]_[localizedRecordUid]'. This in turn + // will not properly trigger the select option "is selected" comparison. + // @see SelectViewHelper->getOptionValueScalar() + return $value->getUid(); + } + $identifier = $this->persistenceManager->getIdentifierByObject($value); + if ($identifier !== null) { + return $identifier; + } + } + return $value; + } + + /** + * Checks if a property mapping error has occurred in the last request. + */ + protected function hasMappingErrorOccurred(): bool + { + /** @var ExtbaseRequestParameters $extbaseRequestParameters */ + $extbaseRequestParameters = $this->getRequest()->getAttribute('extbase'); + return $extbaseRequestParameters->getOriginalRequest() !== null; + } + + /** + * Get the form data which has last been submitted; only returns valid data in case + * a property mapping error has occurred. Check with hasMappingErrorOccurred() before! + * + * @return mixed + */ + protected function getLastSubmittedFormData() + { + $propertyPath = rtrim(preg_replace('/(\\]\\[|\\[|\\])/', '.', $this->getNameWithoutPrefix()) ?? '', '.'); + /** @var ExtbaseRequestParameters $extbaseRequestParameters */ + $extbaseRequestParameters = $this->getRequest()->getAttribute('extbase'); + $value = ObjectAccess::getPropertyPath( + $extbaseRequestParameters->getOriginalRequest()->getArguments(), + $propertyPath + ); + return $value; + } + + /** + * Add additional identity properties in case the current property is hierarchical (of the form "bla.blubb"). + * Then, [bla][__identity] has to be generated as well. + */ + protected function addAdditionalIdentityPropertiesIfNeeded(): void + { + if (!$this->isObjectAccessorMode()) { + return; + } + + $viewHelperVariableContainer = $this->renderingContext->getViewHelperVariableContainer(); + if (!$viewHelperVariableContainer->exists( + FormViewHelper::class, + 'formObject' + ) + ) { + return; + } + $propertySegments = explode('.', (string)($this->arguments['property'] ?? '')); + // hierarchical property. If there is no "." inside (thus $propertySegments == 1), we do not need to do anything + if (count($propertySegments) < 2) { + return; + } + $formObject = $viewHelperVariableContainer->get( + FormViewHelper::class, + 'formObject' + ); + $objectName = $viewHelperVariableContainer->get( + FormViewHelper::class, + 'formObjectName' + ); + // If count == 2 -> we need to go through the for-loop exactly once + $propertySegmentsCount = count($propertySegments); + for ($i = 1; $i < $propertySegmentsCount; $i++) { + $object = ObjectAccess::getPropertyPath($formObject, implode('.', array_slice($propertySegments, 0, $i))); + if (!is_object($object)) { + $object = null; + } + $objectName .= '[' . $propertySegments[$i - 1] . ']'; + $hiddenIdentityField = $this->renderHiddenIdentityField($object, $objectName); + // Add the hidden identity field to the ViewHelperVariableContainer + $additionalIdentityProperties = $viewHelperVariableContainer->get( + FormViewHelper::class, + 'additionalIdentityProperties' + ); + $additionalIdentityProperties[$objectName] = $hiddenIdentityField; + $viewHelperVariableContainer->addOrUpdate( + FormViewHelper::class, + 'additionalIdentityProperties', + $additionalIdentityProperties + ); + } + } + + /** + * Get the current property of the object bound to this form. + * + * @return mixed Value + */ + protected function getPropertyValue() + { + if (!isset($this->arguments['property'])) { + return null; + } + $viewHelperVariableContainer = $this->renderingContext->getViewHelperVariableContainer(); + if (!$viewHelperVariableContainer->exists( + FormViewHelper::class, + 'formObject' + ) + ) { + return null; + } + $formObject = $viewHelperVariableContainer->get( + FormViewHelper::class, + 'formObject' + ); + return ObjectAccess::getPropertyPath($formObject, (string)$this->arguments['property']); + } + + /** + * Internal method which checks if we should evaluate a domain object or just output arguments['name'] + * and arguments['value']. Returns true if domain object should be evaluated. + */ + protected function isObjectAccessorMode(): bool + { + return $this->hasArgument('property') && $this->renderingContext->getViewHelperVariableContainer()->exists( + FormViewHelper::class, + 'formObjectName' + ); + } + + /** + * Add a CSS class if this ViewHelper has errors + */ + protected function setErrorClassAttribute(): void + { + if (isset($this->additionalArguments['class'])) { + $cssClass = $this->additionalArguments['class'] . ' '; + } else { + $cssClass = ''; + } + + $mappingResultsForProperty = $this->getMappingResultsForProperty(); + if ($mappingResultsForProperty->hasErrors()) { + if ($this->hasArgument('errorClass')) { + $cssClass .= $this->arguments['errorClass']; + } else { + $cssClass .= 'error'; + } + $this->tag->addAttribute('class', $cssClass); + } + } + + /** + * Get errors for the property and form name of this ViewHelper + */ + protected function getMappingResultsForProperty(): Result + { + if (!$this->isObjectAccessorMode()) { + return new Result(); + } + /** @var ExtbaseRequestParameters $extbaseRequestParameters */ + $extbaseRequestParameters = $this->getRequest()->getAttribute('extbase'); + $originalRequestMappingResults = $extbaseRequestParameters->getOriginalRequestMappingResults(); + $formObjectName = $this->renderingContext->getViewHelperVariableContainer()->get( + FormViewHelper::class, + 'formObjectName' + ); + return $originalRequestMappingResults->forProperty($formObjectName)->forProperty((string)$this->arguments['property']); + } + + /** + * Renders a hidden field with the same name as the element, to make sure the empty value is submitted + * in case nothing is selected. This is needed for checkbox and multiple select fields + */ + protected function renderHiddenFieldForEmptyValue(): string + { + $hiddenFieldNames = []; + $viewHelperVariableContainer = $this->renderingContext->getViewHelperVariableContainer(); + if ($viewHelperVariableContainer->exists( + FormViewHelper::class, + 'renderedHiddenFields' + ) + ) { + $hiddenFieldNames = $viewHelperVariableContainer->get( + FormViewHelper::class, + 'renderedHiddenFields' + ); + } + $fieldName = $this->getName(); + if (substr($fieldName, -2) === '[]') { + $fieldName = substr($fieldName, 0, -2); + } + if (!in_array($fieldName, $hiddenFieldNames, true)) { + $hiddenFieldNames[] = $fieldName; + $viewHelperVariableContainer->addOrUpdate( + FormViewHelper::class, + 'renderedHiddenFields', + $hiddenFieldNames + ); + return ''; + } + return ''; + } +} diff --git a/Classes/ViewHelpers/Form/AbstractFormViewHelper.php b/Classes/ViewHelpers/Form/AbstractFormViewHelper.php new file mode 100644 index 0000000..49c6ab6 --- /dev/null +++ b/Classes/ViewHelpers/Form/AbstractFormViewHelper.php @@ -0,0 +1,119 @@ +persistenceManager = $persistenceManager; + } + + /** + * Prefixes / namespaces the given name with the form field prefix + */ + protected function prefixFieldName(string $fieldName): string + { + if ($fieldName === '') { + return ''; + } + $viewHelperVariableContainer = $this->renderingContext->getViewHelperVariableContainer(); + if (!$viewHelperVariableContainer->exists(FormViewHelper::class, 'fieldNamePrefix')) { + return $fieldName; + } + $fieldNamePrefix = (string)$viewHelperVariableContainer->get(FormViewHelper::class, 'fieldNamePrefix'); + if ($fieldNamePrefix === '') { + return $fieldName; + } + $fieldNameSegments = explode('[', $fieldName, 2); + $fieldName = $fieldNamePrefix . '[' . $fieldNameSegments[0] . ']'; + if (count($fieldNameSegments) > 1) { + $fieldName .= '[' . $fieldNameSegments[1]; + } + return $fieldName; + } + + /** + * Renders a hidden form field containing the technical identity of the given object. + * + * @param mixed $object Object to create the identity field for. Non-objects are ignored. + * @param string|null $name Name + * @return string A hidden field containing the Identity (uid) of the given object + * @see \TYPO3\CMS\Extbase\Mvc\Controller\Argument::setValue() + */ + protected function renderHiddenIdentityField(mixed $object, ?string $name): string + { + if ($object instanceof LazyLoadingProxy) { + $object = $object->_loadRealInstance(); + } + if (!is_object($object) + || !($object instanceof AbstractDomainObject) + || ($object->_isNew() && !$object->_isClone())) { + return ''; + } + // Intentionally NOT using PersistenceManager::getIdentifierByObject here. + // Using that one breaks re-submission of data in forms in case of an error. + $identifier = $object->getUid(); + if ($identifier === null) { + return LF . '' . LF; + } + $name = $this->prefixFieldName($name ?? '') . '[__identity]'; + $this->registerFieldNameForFormTokenGeneration($name); + + $endingSlash = ($this->shouldUseXHtmlSlash() ? '/' : ''); + return LF . '' . LF; + } + + /** + * Register a field name for inclusion in the HMAC / Form Token generation + */ + protected function registerFieldNameForFormTokenGeneration(string $fieldName): void + { + $viewHelperVariableContainer = $this->renderingContext->getViewHelperVariableContainer(); + if ($viewHelperVariableContainer->exists(FormViewHelper::class, 'formFieldNames')) { + $formFieldNames = $viewHelperVariableContainer->get(FormViewHelper::class, 'formFieldNames'); + } else { + $formFieldNames = []; + } + $formFieldNames[] = $fieldName; + $viewHelperVariableContainer->addOrUpdate(FormViewHelper::class, 'formFieldNames', $formFieldNames); + } + + protected function shouldUseXHtmlSlash(): bool + { + return DocType::createFromRequest($this->renderingContext->getAttribute(ServerRequestInterface::class))->isXmlCompliant(); + } +} diff --git a/Classes/ViewHelpers/Form/ButtonViewHelper.php b/Classes/ViewHelpers/Form/ButtonViewHelper.php new file mode 100644 index 0000000..9439bac --- /dev/null +++ b/Classes/ViewHelpers/Form/ButtonViewHelper.php @@ -0,0 +1,59 @@ +Cancel + * ``` + * + * @see https://docs.typo3.org/permalink/t3viewhelper:typo3-fluid-form-button + */ +final class ButtonViewHelper extends AbstractFormFieldViewHelper +{ + /** + * @var string + */ + protected $tagName = 'button'; + + public function initializeArguments(): void + { + parent::initializeArguments(); + $this->registerArgument('type', 'string', 'Specifies the type of button (e.g. "button", "reset" or "submit")', false, 'submit'); + } + + public function render(): string + { + $type = $this->arguments['type']; + $name = $this->getName(); + $this->registerFieldNameForFormTokenGeneration($name); + + $this->tag->addAttribute('type', $type); + $this->tag->addAttribute('name', $name); + $this->tag->addAttribute('value', (string)$this->getValueAttribute()); + $this->tag->setContent((string)$this->renderChildren()); + $this->tag->forceClosingTag(true); + + return $this->tag->render(); + } +} diff --git a/Classes/ViewHelpers/Form/CheckboxViewHelper.php b/Classes/ViewHelpers/Form/CheckboxViewHelper.php new file mode 100644 index 0000000..6271f59 --- /dev/null +++ b/Classes/ViewHelpers/Form/CheckboxViewHelper.php @@ -0,0 +1,95 @@ +`. + * + * ``` + * + * + * ``` + * + * @see https://docs.typo3.org/permalink/t3viewhelper:typo3-fluid-form-checkbox + */ +final class CheckboxViewHelper extends AbstractFormFieldViewHelper +{ + /** + * @var string + */ + protected $tagName = 'input'; + + public function initializeArguments(): void + { + parent::initializeArguments(); + $this->registerArgument( + 'errorClass', + 'string', + 'CSS class to set if there are errors for this ViewHelper', + false, + 'f3-form-error' + ); + $this->registerArgument('value', 'string', 'Value of input tag. Required for checkboxes', true); + $this->registerArgument('checked', 'bool', 'Specifies that the input element should be preselected'); + $this->registerArgument('multiple', 'bool', 'Specifies whether this checkbox belongs to a multivalue (is part of a checkbox group)', false, false); + } + + public function render(): string + { + $checked = $this->arguments['checked']; + $multiple = $this->arguments['multiple']; + + $this->tag->addAttribute('type', 'checkbox'); + + $nameAttribute = $this->getName(); + $valueAttribute = $this->getValueAttribute(); + $propertyValue = null; + if ($this->hasMappingErrorOccurred()) { + $propertyValue = $this->getLastSubmittedFormData(); + } + if ($checked === null && $propertyValue === null) { + $propertyValue = $this->getPropertyValue(); + } + + if ($propertyValue instanceof \Traversable) { + $propertyValue = iterator_to_array($propertyValue); + } + if (is_array($propertyValue)) { + $propertyValue = array_map($this->convertToPlainValue(...), $propertyValue); + if ($checked === null) { + $checked = in_array($valueAttribute, $propertyValue); + } + $nameAttribute .= '[]'; + } elseif ($multiple === true) { + $nameAttribute .= '[]'; + } elseif ($propertyValue !== null) { + $checked = (bool)$propertyValue === (bool)$valueAttribute; + } + + $this->registerFieldNameForFormTokenGeneration($nameAttribute); + $this->tag->addAttribute('name', $nameAttribute); + $this->tag->addAttribute('value', (string)$valueAttribute); + if ($checked === true) { + $this->tag->addAttribute('checked', 'checked'); + } + + $this->setErrorClassAttribute(); + $hiddenField = $this->renderHiddenFieldForEmptyValue(); + return $hiddenField . $this->tag->render(); + } +} diff --git a/Classes/ViewHelpers/Form/CountrySelectViewHelper.php b/Classes/ViewHelpers/Form/CountrySelectViewHelper.php new file mode 100644 index 0000000..3af4354 --- /dev/null +++ b/Classes/ViewHelpers/Form/CountrySelectViewHelper.php @@ -0,0 +1,221 @@ +` tag with all or specific countries as options. + * + * ``` + * + * + * ``` + * + * @see https://docs.typo3.org/permalink/t3viewhelper:typo3-fluid-form-countryselect + */ +final class CountrySelectViewHelper extends AbstractFormFieldViewHelper +{ + /** + * @var string + */ + protected $tagName = 'select'; + + public function __construct( + private readonly CountryProvider $countryProvider + ) { + parent::__construct(); + } + + public function initializeArguments(): void + { + parent::initializeArguments(); + $this->registerArgument('excludeCountries', 'array', 'Array with country codes that should not be shown.', false, []); + $this->registerArgument('onlyCountries', 'array', 'If set, only the country codes in the list are rendered.', false, []); + $this->registerArgument('optionLabelField', 'string', 'If specified, will call the appropriate getter on each object to determine the label. Use "name", "localizedName", "officialName" or "localizedOfficialName"', false, 'localizedName'); + $this->registerArgument('sortByOptionLabel', 'boolean', 'If true, List will be sorted by label.', false, false); + $this->registerArgument('errorClass', 'string', 'CSS class to set if there are errors for this ViewHelper', false, 'f3-form-error'); + $this->registerArgument('prependOptionLabel', 'string', 'If specified, will provide an option at first position with the specified label.'); + $this->registerArgument('prependOptionValue', 'string', 'If specified, will provide an option at first position with the specified value.'); + $this->registerArgument('multiple', 'boolean', 'If set multiple options may be selected.', false, false); + $this->registerArgument('required', 'boolean', 'If set no empty value is allowed.', false, false); + $this->registerArgument('prioritizedCountries', 'array', 'A list of country codes which should be listed on top of the list.', false, []); + $this->registerArgument('alternativeLanguage', 'string', 'If specified, the country list will be shown in the given language.'); + } + + public function render(): string + { + if ($this->arguments['required']) { + $this->tag->addAttribute('required', 'required'); + } + $name = $this->getName(); + if ($this->arguments['multiple']) { + $this->tag->addAttribute('multiple', 'multiple'); + $name .= '[]'; + } + $this->addAdditionalIdentityPropertiesIfNeeded(); + $this->setErrorClassAttribute(); + $this->registerFieldNameForFormTokenGeneration($name); + $this->setRespectSubmittedDataValue(true); + + $this->tag->addAttribute('name', $name); + + $validCountries = $this->getCountryList(); + $options = $this->createOptions($validCountries); + $selectedValue = $this->getValueAttribute(); + + $tagContent = $this->renderPrependOptionTag(); + foreach ($options as $value => $label) { + $tagContent .= $this->renderOptionTag($value, $label, $value === $selectedValue); + } + + $this->tag->forceClosingTag(true); + $this->tag->setContent($tagContent); + return $this->tag->render(); + } + + /** + * @param Country[] $countries + * @return array + */ + private function createOptions(array $countries): array + { + $options = []; + foreach ($countries as $code => $country) { + switch ($this->arguments['optionLabelField']) { + case 'localizedName': + $options[$code] = $this->translate($country->getLocalizedNameLabel()); + break; + case 'name': + $options[$code] = $country->getName(); + break; + case 'officialName': + $options[$code] = $country->getOfficialName(); + break; + case 'localizedOfficialName': + $name = $this->translate($country->getLocalizedOfficialNameLabel()); + if (!$name) { + $name = $this->translate($country->getLocalizedNameLabel()); + } + $options[$code] = $name; + break; + default: + throw new InvalidArgumentValueException('Argument "optionLabelField" of must either be set to "localizedName", "name", "officialName", or "localizedOfficialName".', 1674076708); + } + } + if ($this->arguments['sortByOptionLabel']) { + asort($options, SORT_LOCALE_STRING); + } else { + ksort($options, SORT_NATURAL); + } + if (($this->arguments['prioritizedCountries'] ?? []) !== []) { + $finalOptions = []; + foreach ($this->arguments['prioritizedCountries'] as $countryCode) { + if (isset($options[$countryCode])) { + $label = $options[$countryCode]; + $finalOptions[$countryCode] = $label; + unset($options[$countryCode]); + } + } + foreach ($options as $countryCode => $label) { + $finalOptions[$countryCode] = $label; + } + $options = $finalOptions; + } + return $options; + } + + private function translate(string $label): string + { + if ($this->arguments['alternativeLanguage']) { + return (string)LocalizationUtility::translate($label, null, null, $this->arguments['alternativeLanguage']); + } + return (string)LocalizationUtility::translate($label); + } + + /** + * Render prepended option tag + */ + private function renderPrependOptionTag(): string + { + if ($this->hasArgument('prependOptionLabel')) { + $value = $this->hasArgument('prependOptionValue') ? $this->arguments['prependOptionValue'] : ''; + $label = $this->arguments['prependOptionLabel']; + return $this->renderOptionTag((string)$value, (string)$label, false) . LF; + } + return ''; + } + + /** + * Render one option tag + * + * @param string $value value attribute of the option tag (will be escaped) + * @param string $label content of the option tag (will be escaped) + * @param bool $isSelected specifies whether to add selected attribute + * @return string the rendered option tag + */ + private function renderOptionTag(string $value, string $label, bool $isSelected): string + { + $output = ''; + return $output; + } + + /** + * @return Country[] + */ + private function getCountryList(): array + { + $filter = new CountryFilter(); + $filter->setOnlyCountries($this->arguments['onlyCountries'] ?? []) + ->setExcludeCountries($this->arguments['excludeCountries'] ?? []); + return $this->countryProvider->getFiltered($filter); + } + + /** + * Converts an arbitrary value to a plain value. + * Evaluates possible direct "Country" type properties. + * + * @param mixed $value The value to convert + * @return mixed + */ + protected function convertToPlainValue($value) + { + if ($value instanceof Country) { + return $value->getAlpha2IsoCode(); + } + return parent::convertToPlainValue($value); + } +} diff --git a/Classes/ViewHelpers/Form/HiddenViewHelper.php b/Classes/ViewHelpers/Form/HiddenViewHelper.php new file mode 100644 index 0000000..68ced8d --- /dev/null +++ b/Classes/ViewHelpers/Form/HiddenViewHelper.php @@ -0,0 +1,62 @@ +` tag. + * + * ``` + * + * ``` + * + * @see https://docs.typo3.org/permalink/t3viewhelper:typo3-fluid-form-hidden + */ +final class HiddenViewHelper extends AbstractFormFieldViewHelper +{ + /** + * @var string + */ + protected $tagName = 'input'; + + public function initializeArguments(): void + { + parent::initializeArguments(); + $this->registerArgument( + 'respectSubmittedDataValue', + 'bool', + 'enable or disable the usage of the submitted values', + false, + true + ); + } + + public function render(): string + { + $name = $this->getName(); + $this->registerFieldNameForFormTokenGeneration($name); + $this->setRespectSubmittedDataValue($this->arguments['respectSubmittedDataValue']); + + $this->tag->addAttribute('type', 'hidden'); + $this->tag->addAttribute('name', $name); + $this->tag->addAttribute('value', (string)$this->getValueAttribute()); + + $this->addAdditionalIdentityPropertiesIfNeeded(); + + return $this->tag->render(); + } +} diff --git a/Classes/ViewHelpers/Form/PasswordViewHelper.php b/Classes/ViewHelpers/Form/PasswordViewHelper.php new file mode 100644 index 0000000..e13ab0f --- /dev/null +++ b/Classes/ViewHelpers/Form/PasswordViewHelper.php @@ -0,0 +1,64 @@ +`. + * + * ``` + * + * ``` + * + * @see https://docs.typo3.org/permalink/t3viewhelper:typo3-fluid-form-password + */ +final class PasswordViewHelper extends AbstractFormFieldViewHelper +{ + /** + * @var string + */ + protected $tagName = 'input'; + + public function initializeArguments(): void + { + parent::initializeArguments(); + $this->registerArgument('errorClass', 'string', 'CSS class to set if there are errors for this ViewHelper', false, 'f3-form-error'); + $this->registerArgument( + 'respectSubmittedDataValue', + 'bool', + 'If set to false (default), any user-submitted data is not displayed in the output. If set to true, the password is emitted as clear text in the response. This is not recommended from a security point of view.', + false, + false + ); + } + + public function render(): string + { + $name = $this->getName(); + $this->registerFieldNameForFormTokenGeneration($name); + $this->setRespectSubmittedDataValue($this->arguments['respectSubmittedDataValue']); + + $this->tag->addAttribute('type', 'password'); + $this->tag->addAttribute('name', $name); + $this->tag->addAttribute('value', (string)$this->getValueAttribute()); + + $this->addAdditionalIdentityPropertiesIfNeeded(); + $this->setErrorClassAttribute(); + + return $this->tag->render(); + } +} diff --git a/Classes/ViewHelpers/Form/RadioViewHelper.php b/Classes/ViewHelpers/Form/RadioViewHelper.php new file mode 100644 index 0000000..078007d --- /dev/null +++ b/Classes/ViewHelpers/Form/RadioViewHelper.php @@ -0,0 +1,79 @@ +`. + * + * ``` + * + * + * ``` + * + * @see https://docs.typo3.org/permalink/t3viewhelper:typo3-fluid-form-radio + */ +final class RadioViewHelper extends AbstractFormFieldViewHelper +{ + /** + * @var string + */ + protected $tagName = 'input'; + + public function initializeArguments(): void + { + parent::initializeArguments(); + $this->registerArgument('errorClass', 'string', 'CSS class to set if there are errors for this ViewHelper', false, 'f3-form-error'); + $this->registerArgument('checked', 'bool', 'Specifies that the input element should be preselected'); + $this->registerArgument('value', 'string', 'Value of input tag. Required for radio buttons', true); + } + + public function render(): string + { + $checked = $this->arguments['checked']; + + $this->tag->addAttribute('type', 'radio'); + + $nameAttribute = $this->getName(); + $valueAttribute = $this->getValueAttribute(); + + $propertyValue = null; + if ($this->hasMappingErrorOccurred()) { + $propertyValue = $this->getLastSubmittedFormData(); + } + if ($checked === null && $propertyValue === null) { + $propertyValue = $this->getPropertyValue(); + $propertyValue = $this->convertToPlainValue($propertyValue); + } + + if ($propertyValue !== null) { + // no type-safe comparison by intention + $checked = $propertyValue == $valueAttribute; + } + + $this->registerFieldNameForFormTokenGeneration($nameAttribute); + $this->tag->addAttribute('name', $nameAttribute); + $this->tag->addAttribute('value', (string)$valueAttribute); + if ($checked === true) { + $this->tag->addAttribute('checked', 'checked'); + } + + $this->setErrorClassAttribute(); + + return $this->tag->render(); + } +} diff --git a/Classes/ViewHelpers/Form/Select/OptgroupViewHelper.php b/Classes/ViewHelpers/Form/Select/OptgroupViewHelper.php new file mode 100644 index 0000000..133d59c --- /dev/null +++ b/Classes/ViewHelpers/Form/Select/OptgroupViewHelper.php @@ -0,0 +1,63 @@ +` tags inside a ``, + * supports further child `` tags. + * + * ``` + * + * Option one + * + * Grouped option one + * Grouped option two + * + * > + * ``` + * + * @see https://docs.typo3.org/permalink/t3viewhelper:typo3-fluid-form-select-optgroup + * @see https://docs.typo3.org/permalink/t3viewhelper:typo3-fluid-form-select-option + * @see https://docs.typo3.org/permalink/t3viewhelper:typo3-fluid-form-select + */ +final class OptgroupViewHelper extends AbstractFormFieldViewHelper +{ + /** + * @var string + */ + protected $tagName = 'optgroup'; + + public function initializeArguments(): void + { + $this->registerArgument('additionalAttributes', 'array', 'Additional tag attributes. They will be added directly to the resulting HTML tag.'); + $this->registerArgument('data', 'array', 'Additional data-* attributes. They will each be added with a "data-" prefix.'); + $this->registerArgument('disabled', 'boolean', 'If true, option group is rendered as disabled', false, false); + } + + public function render(): string + { + if ($this->arguments['disabled']) { + $this->tag->addAttribute('disabled', 'disabled'); + } + + $this->tag->setContent($this->renderChildren()); + return $this->tag->render(); + } +} diff --git a/Classes/ViewHelpers/Form/Select/OptionViewHelper.php b/Classes/ViewHelpers/Form/Select/OptionViewHelper.php new file mode 100644 index 0000000..ce583c2 --- /dev/null +++ b/Classes/ViewHelpers/Form/Select/OptionViewHelper.php @@ -0,0 +1,87 @@ +` tags inside a ``. + * + * ``` + * + * Option one + * + * Grouped option one + * Grouped option two + * + * > + * ``` + * + * @see https://docs.typo3.org/permalink/t3viewhelper:typo3-fluid-form-select-option + * @see https://docs.typo3.org/permalink/t3viewhelper:typo3-fluid-form-select + */ +final class OptionViewHelper extends AbstractFormFieldViewHelper +{ + /** + * @var string + */ + protected $tagName = 'option'; + + public function initializeArguments(): void + { + $this->registerArgument('selected', 'boolean', 'If set, overrides automatic detection of selected state for this option.'); + $this->registerArgument('additionalAttributes', 'array', 'Additional tag attributes. They will be added directly to the resulting HTML tag.'); + $this->registerArgument('data', 'array', 'Additional data-* attributes. They will each be added with a "data-" prefix.'); + $this->registerArgument('value', 'mixed', 'Value to be inserted in HTML tag - must be convertible to string!'); + } + + public function render(): string + { + $childContent = $this->renderChildren(); + $this->tag->setContent((string)$childContent); + $value = $this->arguments['value'] ?? $childContent; + if ($this->arguments['selected'] ?? $this->isValueSelected((string)$value)) { + $this->tag->addAttribute('selected', 'selected'); + } + $this->tag->addAttribute('value', (string)$value); + $parentRequestedFormTokenFieldName = $this->renderingContext->getViewHelperVariableContainer()->get( + SelectViewHelper::class, + 'registerFieldNameForFormTokenGeneration' + ); + if ($parentRequestedFormTokenFieldName) { + // parent (select field) has requested this option must add one more + // entry in the token generation registry for one additional potential + // value of the field. Happens when "multiple" is true on parent. + $this->registerFieldNameForFormTokenGeneration($parentRequestedFormTokenFieldName); + } + return $this->tag->render(); + } + + private function isValueSelected(string $value): bool + { + $selectedValue = $this->renderingContext->getViewHelperVariableContainer()->get(SelectViewHelper::class, 'selectedValue'); + if (is_array($selectedValue)) { + return in_array($value, array_map(strval(...), $selectedValue), true); + } + if ($selectedValue instanceof \Iterator) { + return in_array($value, array_map(strval(...), iterator_to_array($selectedValue)), true); + } + return $value === (string)$selectedValue; + } +} diff --git a/Classes/ViewHelpers/Form/SelectViewHelper.php b/Classes/ViewHelpers/Form/SelectViewHelper.php new file mode 100644 index 0000000..2170559 --- /dev/null +++ b/Classes/ViewHelpers/Form/SelectViewHelper.php @@ -0,0 +1,305 @@ +` dropdown list for use within a form. + * + * ``` + * + * + * ``` + * + * @see https://docs.typo3.org/permalink/t3viewhelper:typo3-fluid-form-select + */ +final class SelectViewHelper extends AbstractFormFieldViewHelper +{ + /** + * @var string + */ + protected $tagName = 'select'; + + public function initializeArguments(): void + { + parent::initializeArguments(); + $this->registerArgument('options', 'array', 'Associative array with internal IDs as key, and the values are displayed in the select box. Can be combined with or replaced by child f:form.select.* nodes.'); + $this->registerArgument('optionsAfterContent', 'boolean', 'If true, places auto-generated option tags after those rendered in the tag content. If false, automatic options come first.', false, false); + $this->registerArgument('optionValueField', 'string', 'If specified, will call the appropriate getter on each object to determine the value.'); + $this->registerArgument('optionLabelField', 'string', 'If specified, will call the appropriate getter on each object to determine the label.'); + $this->registerArgument('sortByOptionLabel', 'boolean', 'If true, List will be sorted by label.', false, false); + $this->registerArgument('selectAllByDefault', 'boolean', 'If specified options are selected if none was set before.', false, false); + $this->registerArgument('errorClass', 'string', 'CSS class to set if there are errors for this ViewHelper', false, 'f3-form-error'); + $this->registerArgument('prependOptionLabel', 'string', 'If specified, will provide an option at first position with the specified label.'); + $this->registerArgument('prependOptionValue', 'string', 'If specified, will provide an option at first position with the specified value.'); + $this->registerArgument('multiple', 'boolean', 'If set multiple options may be selected.', false, false); + $this->registerArgument('required', 'boolean', 'If set no empty value is allowed.', false, false); + } + + public function render(): string + { + if ($this->arguments['required']) { + $this->tag->addAttribute('required', 'required'); + } + $name = $this->getName(); + if ($this->arguments['multiple']) { + $this->tag->addAttribute('multiple', 'multiple'); + $name .= '[]'; + } + $this->tag->addAttribute('name', $name); + $options = $this->getOptions(); + + $viewHelperVariableContainer = $this->renderingContext->getViewHelperVariableContainer(); + + $this->addAdditionalIdentityPropertiesIfNeeded(); + $this->setErrorClassAttribute(); + $content = ''; + + // register field name for token generation. + $this->registerFieldNameForFormTokenGeneration($name); + // in case it is a multi-select, we need to register the field name + // as often as there are elements in the box + if ($this->arguments['multiple']) { + $content .= $this->renderHiddenFieldForEmptyValue(); + // Register the field name additional times as required by the total number of + // options. Since we already registered it once above, we start the counter at 1 + // instead of 0. + $optionsCount = count($options); + for ($i = 1; $i < $optionsCount; $i++) { + $this->registerFieldNameForFormTokenGeneration($name); + } + // save the parent field name so that any child f:form.select.option + // tag will know to call registerFieldNameForFormTokenGeneration + // this is the reason why "self::class" is used instead of static::class (no LSB) + $viewHelperVariableContainer->addOrUpdate( + self::class, + 'registerFieldNameForFormTokenGeneration', + $name + ); + } + + $viewHelperVariableContainer->addOrUpdate(self::class, 'selectedValue', $this->getSelectedValue()); + $prependContent = $this->renderPrependOptionTag(); + $tagContent = $this->renderOptionTags($options); + $childContent = $this->renderChildren(); + $viewHelperVariableContainer->remove(self::class, 'selectedValue'); + $viewHelperVariableContainer->remove(self::class, 'registerFieldNameForFormTokenGeneration'); + if (isset($this->arguments['optionsAfterContent']) && $this->arguments['optionsAfterContent']) { + $tagContent = $childContent . $tagContent; + } else { + $tagContent .= $childContent; + } + $tagContent = $prependContent . $tagContent; + + $this->tag->forceClosingTag(true); + $this->tag->setContent($tagContent); + $content .= $this->tag->render(); + return $content; + } + + /** + * Render prepended option tag + */ + private function renderPrependOptionTag(): string + { + $output = ''; + if ($this->hasArgument('prependOptionLabel')) { + $value = $this->hasArgument('prependOptionValue') ? $this->arguments['prependOptionValue'] : ''; + $label = $this->arguments['prependOptionLabel']; + $output .= $this->renderOptionTag((string)$value, (string)$label, false) . LF; + } + return $output; + } + + /** + * Render the option tags. + */ + private function renderOptionTags(array $options): string + { + $output = ''; + foreach ($options as $value => $label) { + $isSelected = $this->isSelected($value); + $output .= $this->renderOptionTag((string)$value, (string)$label, $isSelected) . LF; + } + return $output; + } + + /** + * Render the option tags. + * + * @return array An associative array of options, key will be the value of the option tag + */ + private function getOptions(): array + { + if (!is_array($this->arguments['options']) && !$this->arguments['options'] instanceof \Traversable) { + return []; + } + $options = []; + $optionsArgument = $this->arguments['options']; + foreach ($optionsArgument as $key => $value) { + if (!is_object($value) && !is_array($value)) { + $options[$key] = $value; + continue; + } + if (is_array($value)) { + if (!$this->hasArgument('optionValueField')) { + throw new MissingArgumentException('Missing parameter "optionValueField" in SelectViewHelper for array value options.', 1682693720); + } + if (!$this->hasArgument('optionLabelField')) { + throw new MissingArgumentException('Missing parameter "optionLabelField" in SelectViewHelper for array value options.', 1682693721); + } + $key = ObjectAccess::getPropertyPath($value, (string)$this->arguments['optionValueField']); + $value = ObjectAccess::getPropertyPath($value, (string)$this->arguments['optionLabelField']); + $options[$key ?? ''] = $value; + continue; + } + if ($this->hasArgument('optionValueField')) { + $key = ObjectAccess::getPropertyPath($value, $this->arguments['optionValueField']); + if (is_object($key)) { + if (method_exists($key, '__toString')) { + $key = (string)$key; + } else { + throw new InvalidArgumentValueException('Identifying value for object of class "' . get_debug_type($value) . '" was an object.', 1247827428); + } + } + } elseif (!$this->persistenceManager->isNewObject($value)) { + $key = $this->persistenceManager->getIdentifierByObject($value); + } elseif (is_object($value) && method_exists($value, '__toString')) { + $key = (string)$value; + } elseif (is_object($value)) { + throw new InvalidArgumentValueException('No identifying value for object of class "' . get_class($value) . '" found.', 1247826696); + } + if ($this->hasArgument('optionLabelField')) { + $value = ObjectAccess::getPropertyPath($value, $this->arguments['optionLabelField']); + if (is_object($value)) { + if (method_exists($value, '__toString')) { + $value = (string)$value; + } else { + throw new InvalidArgumentValueException('Label value for object of class "' . get_class($value) . '" was an object without a __toString() method.', 1247827553); + } + } + } elseif (is_object($value) && method_exists($value, '__toString')) { + $value = (string)$value; + } elseif (!$this->persistenceManager->isNewObject($value)) { + $value = $this->persistenceManager->getIdentifierByObject($value); + } + $options[$key ?? ''] = $value; + } + if ($this->arguments['sortByOptionLabel']) { + asort($options, SORT_LOCALE_STRING); + } + return $options; + } + + /** + * Render the option tags. + * + * @param mixed $value Value to check for + * @return bool True if the value should be marked as selected. + */ + private function isSelected($value): bool + { + $selectedValue = $this->getSelectedValue(); + if ($value === $selectedValue || (string)$value === $selectedValue) { + return true; + } + if ($this->hasArgument('multiple')) { + if ($selectedValue === null && $this->arguments['selectAllByDefault'] === true) { + return true; + } + if (is_array($selectedValue) && in_array($value, $selectedValue)) { + return true; + } + } + return false; + } + + /** + * Retrieves the selected value(s) + * + * @return mixed value string or an array of strings + */ + private function getSelectedValue() + { + $this->setRespectSubmittedDataValue(true); + $value = $this->getValueAttribute(); + if (!is_array($value) && !$value instanceof \Traversable) { + return $this->getOptionValueScalar($value); + } + $selectedValues = []; + foreach ($value as $selectedValueElement) { + $selectedValues[] = $this->getOptionValueScalar($selectedValueElement); + } + return $selectedValues; + } + + /** + * Get the option value for an object + * + * @param mixed $valueElement + * @return string @todo: Does not always return string ... + */ + private function getOptionValueScalar($valueElement) + { + if (is_object($valueElement)) { + if ($this->hasArgument('optionValueField')) { + return ObjectAccess::getPropertyPath($valueElement, $this->arguments['optionValueField']); + } + if (!$this->persistenceManager->isNewObject($valueElement)) { + if ($valueElement instanceof DomainObjectInterface) { + // We prefer to use the `getUid()` method because this returns the properly overlaid identifier (defaultLanguageRecordUid). + // Otherwise, an identifier would contain '[defaultLanguageRecordUid]_[localizedRecordUid]'. This in turn + // will not properly trigger the select option "is selected" comparison. + // @see AbstractFormFieldViewHelper->convertToPlainValue() + return $valueElement->getUid() ?? $this->persistenceManager->getIdentifierByObject($valueElement); + } + return $this->persistenceManager->getIdentifierByObject($valueElement); + } + if ($valueElement instanceof \BackedEnum) { + return $valueElement->value; + } + if ($valueElement instanceof \UnitEnum) { + return $valueElement->name; + } + return (string)$valueElement; + } + return $valueElement; + } + + /** + * Render one option tag + * + * @param string $value value attribute of the option tag (will be escaped) + * @param string $label content of the option tag (will be escaped) + * @param bool $isSelected specifies whether to add selected attribute + * @return string the rendered option tag + */ + private function renderOptionTag(string $value, string $label, bool $isSelected): string + { + $output = '