diff --git a/Classes/Command/NodeTemplateCommandController.php b/Classes/Application/Command/NodeTemplateCommandController.php similarity index 91% rename from Classes/Command/NodeTemplateCommandController.php rename to Classes/Application/Command/NodeTemplateCommandController.php index d30c82c..4cefd74 100644 --- a/Classes/Command/NodeTemplateCommandController.php +++ b/Classes/Application/Command/NodeTemplateCommandController.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flowpack\NodeTemplates\Command; +namespace Flowpack\NodeTemplates\Application\Command; -use Flowpack\NodeTemplates\NodeTemplateDumper\NodeTemplateDumper; +use Flowpack\NodeTemplates\Domain\NodeTemplateDumper\NodeTemplateDumper; use Neos\Flow\Annotations as Flow; use Neos\Flow\Cli\CommandController; use Neos\Neos\Domain\Service\ContentContextFactory; diff --git a/Classes/Domain/DelegatingDocumentTitleNodeCreationHandler.php b/Classes/Domain/DelegatingDocumentTitleNodeCreationHandler.php new file mode 100644 index 0000000..2dda3e8 --- /dev/null +++ b/Classes/Domain/DelegatingDocumentTitleNodeCreationHandler.php @@ -0,0 +1,43 @@ +getNodeType()->getOptions()['template'] ?? null; + if ( + !$template + || !isset($template['properties']['uriPathSegment']) + ) { + $this->originalDocumentTitleNodeCreationHandler->handle($node, $data); + return; + } + + // do nothing, as we handle this already when applying the template + } +} diff --git a/Classes/Domain/ExceptionHandling/CaughtException.php b/Classes/Domain/ExceptionHandling/CaughtException.php new file mode 100644 index 0000000..7a12c85 --- /dev/null +++ b/Classes/Domain/ExceptionHandling/CaughtException.php @@ -0,0 +1,71 @@ +exception = $exception; + $this->origin = $origin; + } + + public static function fromException(\Throwable $exception): self + { + return new self($exception, null); + } + + public function withOrigin(string $origin): self + { + return new self($this->exception, $origin); + } + + public function getException(): \Throwable + { + return $this->exception; + } + + public function getOrigin(): ?string + { + return $this->origin; + } + + public function toMessage(): string + { + $messageLines = []; + + if ($this->origin) { + $messageLines[] = $this->origin; + } + + $level = 0; + $exception = $this->exception; + do { + $level++; + if ($level >= 8) { + $messageLines[] = '...Recursion'; + break; + } + + $reflexception = new \ReflectionClass($exception); + $shortExceptionName = $reflexception->getShortName(); + if ($shortExceptionName === 'Exception') { + $secondPartOfPackageName = explode('\\', $reflexception->getNamespaceName())[1] ?? ''; + $shortExceptionName = $secondPartOfPackageName . $shortExceptionName; + } + $messageLines[] = sprintf('%s(%s, %s)', $shortExceptionName, $exception->getMessage(), $exception->getCode()); + } while ($exception = $exception->getPrevious()); + + return join(' | ', $messageLines); + } +} diff --git a/Classes/Domain/ExceptionHandling/CaughtExceptions.php b/Classes/Domain/ExceptionHandling/CaughtExceptions.php new file mode 100644 index 0000000..7710bec --- /dev/null +++ b/Classes/Domain/ExceptionHandling/CaughtExceptions.php @@ -0,0 +1,45 @@ + */ + private array $exceptions = []; + + private function __construct() + { + } + + public static function create(): self + { + return new self(); + } + + public function hasExceptions(): bool + { + return $this->exceptions !== []; + } + + public function add(CaughtException $exception): void + { + $this->exceptions[] = $exception; + } + + public function first(): ?CaughtException + { + return $this->exceptions[0] ?? null; + } + + /** + * @return \Traversable|CaughtException[] + */ + public function getIterator() + { + yield from $this->exceptions; + } +} diff --git a/Classes/Domain/ExceptionHandling/ExceptionHandler.php b/Classes/Domain/ExceptionHandling/ExceptionHandler.php new file mode 100644 index 0000000..5fff117 --- /dev/null +++ b/Classes/Domain/ExceptionHandling/ExceptionHandler.php @@ -0,0 +1,107 @@ +hasExceptions()) { + return; + } + + if (!$this->configuration->shouldStopOnExceptionAfterTemplateConfigurationProcessing()) { + return; + } + + $templateNotCreatedException = new TemplateNotCreatedException( + sprintf('Template for "%s" was not applied. Only %s was created.', $node->getNodeType()->getLabel(), (string)$node), + 1686135532992, + $caughtExceptions->first()->getException(), + ); + + $this->logCaughtExceptions($caughtExceptions, $templateNotCreatedException); + + throw $templateNotCreatedException; + } + + public function handleAfterNodeCreation(CaughtExceptions $caughtExceptions, NodeInterface $node): void + { + if (!$caughtExceptions->hasExceptions()) { + return; + } + + $templatePartiallyCreatedException = new TemplatePartiallyCreatedException( + sprintf('Template for "%s" only partially applied. Please check the newly created nodes beneath %s.', $node->getNodeType()->getLabel(), (string)$node), + 1686135564160, + $caughtExceptions->first()->getException(), + ); + + $this->logCaughtExceptions($caughtExceptions, $templatePartiallyCreatedException); + + throw $templatePartiallyCreatedException; + } + + /** + * @param TemplateNotCreatedException|TemplatePartiallyCreatedException $templateCreationException + */ + private function logCaughtExceptions(CaughtExceptions $caughtExceptions, \DomainException $templateCreationException): void + { + $messages = []; + foreach ($caughtExceptions as $index => $caughtException) { + $messages[sprintf('CaughtException (%s)', $index)] = $caughtException->toMessage(); + } + + // log exception + $messageWithReference = $this->throwableStorage->logThrowable($templateCreationException, $messages); + $this->logger->warning($messageWithReference, LogEnvironment::fromMethodName(__METHOD__)); + + // neos ui logging + $nodeTemplateError = new Error(); + $nodeTemplateError->setMessage($templateCreationException->getMessage()); + + $this->feedbackCollection->add( + $nodeTemplateError + ); + + foreach ($messages as $message) { + $error = new Error(); + $error->setMessage($message); + $this->feedbackCollection->add( + $error + ); + } + } +} diff --git a/Classes/Domain/ExceptionHandling/ExceptionHandlingConfiguration.php b/Classes/Domain/ExceptionHandling/ExceptionHandlingConfiguration.php new file mode 100644 index 0000000..bbd29c5 --- /dev/null +++ b/Classes/Domain/ExceptionHandling/ExceptionHandlingConfiguration.php @@ -0,0 +1,18 @@ +exceptionHandlingConfiguration['templateConfigurationProcessing']['stopOnException'] ?? false; + } +} diff --git a/Classes/Domain/ExceptionHandling/TemplateNotCreatedException.php b/Classes/Domain/ExceptionHandling/TemplateNotCreatedException.php new file mode 100644 index 0000000..5922a1f --- /dev/null +++ b/Classes/Domain/ExceptionHandling/TemplateNotCreatedException.php @@ -0,0 +1,11 @@ +getNodeType(); + $propertiesAndReferences = PropertiesAndReferences::createFromArrayAndTypeDeclarations($template->getProperties(), $nodeType); + + // set properties + foreach ($propertiesAndReferences->requireValidProperties($nodeType, $caughtExceptions) as $key => $value) { + $node->setProperty($key, $value); + } + + // set references + foreach ($propertiesAndReferences->requireValidReferences($nodeType, $node->getContext(), $caughtExceptions) as $key => $value) { + $node->setProperty($key, $value); + } + + $this->ensureNodeHasUriPathSegment($node, $template); + $this->applyTemplateRecursively($template->getChildNodes(), $node, $caughtExceptions); + } + + private function applyTemplateRecursively(Templates $templates, NodeInterface $parentNode, CaughtExceptions $caughtExceptions): void + { + foreach ($templates as $template) { + if ($template->getName() && $parentNode->getNodeType()->hasAutoCreatedChildNode($template->getName())) { + $node = $parentNode->getNode($template->getName()->__toString()); + if ($template->getType() !== null) { + $caughtExceptions->add( + CaughtException::fromException(new \RuntimeException(sprintf('Template cant mutate type of auto created child nodes. Got: "%s"', $template->getType()->getValue()), 1685999829307)) + ); + // we continue processing the node + } + } else { + if ($template->getType() === null) { + $caughtExceptions->add( + CaughtException::fromException(new \RuntimeException(sprintf('Template requires type to be set for non auto created child nodes.'), 1685999829307)) + ); + continue; + } + if (!$this->nodeTypeManager->hasNodeType($template->getType()->getValue())) { + $caughtExceptions->add( + CaughtException::fromException(new \RuntimeException(sprintf('Template requires type to be a valid NodeType. Got: "%s".', $template->getType()->getValue()), 1685999795564)) + ); + continue; + } + try { + $node = $this->nodeOperations->create( + $parentNode, + [ + 'nodeType' => $template->getType()->getValue(), + 'nodeName' => $template->getName() ? $template->getName()->__toString() : null + ], + 'into' + ); + } catch (NodeConstraintException $nodeConstraintException) { + $caughtExceptions->add( + CaughtException::fromException($nodeConstraintException) + ); + continue; // try the next childNode + } + } + $nodeType = $node->getNodeType(); + $propertiesAndReferences = PropertiesAndReferences::createFromArrayAndTypeDeclarations($template->getProperties(), $nodeType); + + // set properties + foreach ($propertiesAndReferences->requireValidProperties($nodeType, $caughtExceptions) as $key => $value) { + $node->setProperty($key, $value); + } + + // set references + foreach ($propertiesAndReferences->requireValidReferences($nodeType, $node->getContext(), $caughtExceptions) as $key => $value) { + $node->setProperty($key, $value); + } + + $this->ensureNodeHasUriPathSegment($node, $template); + $this->applyTemplateRecursively($template->getChildNodes(), $node, $caughtExceptions); + } + } + + /** + * All document node types get a uri path segment; if it is not explicitly set in the properties, + * it should be built based on the title property + * + * @param Template|RootTemplate $template + */ + private function ensureNodeHasUriPathSegment(NodeInterface $node, $template) + { + if (!$node->getNodeType()->isOfType('Neos.Neos:Document')) { + return; + } + $properties = $template->getProperties(); + if (isset($properties['uriPathSegment'])) { + return; + } + $node->setProperty('uriPathSegment', $this->nodeUriPathSegmentGenerator->generateUriPathSegment($node, $properties['title'] ?? null)); + } +} diff --git a/Classes/Domain/NodeCreation/PropertiesAndReferences.php b/Classes/Domain/NodeCreation/PropertiesAndReferences.php new file mode 100644 index 0000000..fd26a2b --- /dev/null +++ b/Classes/Domain/NodeCreation/PropertiesAndReferences.php @@ -0,0 +1,129 @@ +properties = $properties; + $this->references = $references; + } + + public static function createFromArrayAndTypeDeclarations(array $propertiesAndReferences, NodeType $nodeType): self + { + $references = []; + $properties = []; + foreach ($propertiesAndReferences as $propertyName => $propertyValue) { + $declaration = $nodeType->getPropertyType($propertyName); + if ($declaration === 'reference' || $declaration === 'references') { + $references[$propertyName] = $propertyValue; + continue; + } + $properties[$propertyName] = $propertyValue; + } + return new self($properties, $references); + } + + /** + * A few checks are run against the properties before they are applied on the node. + * + * 1. It is checked, that only properties will be set, that were declared in the NodeType + * + * 2. In case the property is a select-box, it is checked, that the current value is a valid option of the select-box + * + * 3. It is made sure is that a property value is not null when there is a default value: + * In case that due to a condition in the nodeTemplate `null` is assigned to a node property, it will override the defaultValue. + * This is a problem, as setting `null` might not be possible via the Neos UI and the Fusion rendering is most likely not going to handle this edge case. + * Related discussion {@link https://github.com/Flowpack/Flowpack.NodeTemplates/issues/41} + */ + public function requireValidProperties(NodeType $nodeType, CaughtExceptions $caughtExceptions): array + { + $validProperties = []; + foreach ($this->properties as $propertyName => $propertyValue) { + try { + $this->assertValidPropertyName($propertyName); + if (!isset($nodeType->getProperties()[$propertyName])) { + throw new PropertyIgnoredException( + sprintf( + 'Because property is not declared in NodeType. Got value `%s`.', + json_encode($propertyValue) + ), + 1685869035209 + ); + } + $propertyType = PropertyType::fromPropertyOfNodeType($propertyName, $nodeType); + if (!$propertyType->isMatchedBy($propertyValue)) { + throw new PropertyIgnoredException( + sprintf( + 'Because value `%s` is not assignable to property type "%s".', + json_encode($propertyValue), + $propertyType->getValue() + ), + 1685958105644 + ); + } + $validProperties[$propertyName] = $propertyValue; + } catch (PropertyIgnoredException $propertyNotSetException) { + $caughtExceptions->add( + CaughtException::fromException($propertyNotSetException)->withOrigin(sprintf('Property "%s" in NodeType "%s"', $propertyName, $nodeType->getName())) + ); + } + } + return $validProperties; + } + + public function requireValidReferences(NodeType $nodeType, Context $subgraph, CaughtExceptions $caughtExceptions): array + { + $validReferences = []; + foreach ($this->references as $referenceName => $referenceValue) { + $referenceType = ReferenceType::fromPropertyOfNodeType($referenceName, $nodeType); + if (!$referenceType->isMatchedBy($referenceValue, $subgraph)) { + $caughtExceptions->add(CaughtException::fromException(new \RuntimeException( + sprintf( + 'Reference could not be set, because node reference(s) %s cannot be resolved.', + json_encode($referenceValue) + ), + 1685958176560 + ))->withOrigin(sprintf('Reference "%s" in NodeType "%s"', $referenceName, $nodeType->getName()))); + continue; + } + $validReferences[$referenceName] = $referenceValue; + } + return $validReferences; + } + + /** + * In the old CR, it was common practice to set internal or meta properties via this syntax: `_hidden` but we don't allow this anymore. + * @throws PropertyIgnoredException + */ + private function assertValidPropertyName($propertyName): void + { + $legacyInternalProperties = ['_accessRoles', '_contentObject', '_hidden', '_hiddenAfterDateTime', '_hiddenBeforeDateTime', '_hiddenInIndex', + '_index', '_name', '_nodeType', '_removed', '_workspace']; + if (!is_string($propertyName) || $propertyName === '') { + throw new PropertyIgnoredException(sprintf('Because property name must be a non empty string. Got "%s".', $propertyName), 1686149518395); + } + if ($propertyName[0] === '_') { + $lowerPropertyName = strtolower($propertyName); + foreach ($legacyInternalProperties as $legacyInternalProperty) { + if ($lowerPropertyName === strtolower($legacyInternalProperty)) { + throw new PropertyIgnoredException(sprintf('Because internal legacy property "%s" not implement.', $propertyName), 1686149513158); + } + } + } + } +} diff --git a/Classes/Domain/NodeCreation/PropertyIgnoredException.php b/Classes/Domain/NodeCreation/PropertyIgnoredException.php new file mode 100644 index 0000000..a6180ad --- /dev/null +++ b/Classes/Domain/NodeCreation/PropertyIgnoredException.php @@ -0,0 +1,7 @@ +]+>/'; + + private string $value; + + private ?self $arrayOfType; + + private function __construct( + string $value + ) { + $this->value = $value; + if ($this->isArrayOf()) { + $arrayOfType = self::tryFromString($this->getArrayOf()); + if (!$arrayOfType && !$arrayOfType->isArray()) { + throw new \DomainException(sprintf( + 'Array declaration "%s" has invalid subType. Expected either class string or int', + $this->value + )); + } + $this->arrayOfType = $arrayOfType; + } + } + + public static function fromPropertyOfNodeType( + string $propertyName, + NodeType $nodeType + ): self { + $declaration = $nodeType->getPropertyType($propertyName); + if ($declaration === 'reference' || $declaration === 'references') { + throw new \DomainException( + sprintf( + 'Given property "%s" is declared as "reference" in node type "%s" and must be treated as such.', + $propertyName, + $nodeType->getName() + ), + 1685964835205 + ); + } + $type = self::tryFromString($declaration); + if (!$type) { + throw new \DomainException( + sprintf( + 'Given property "%s" is declared as undefined type "%s" in node type "%s"', + $propertyName, + $declaration, + $nodeType->getName() + ), + 1685952798732 + ); + } + return $type; + } + + public static function tryFromString(string $declaration): ?self + { + if ($declaration === 'reference' || $declaration === 'references') { + return null; + } + if ($declaration === 'bool' || $declaration === 'boolean') { + return self::bool(); + } + if ($declaration === 'int' || $declaration === 'integer') { + return self::int(); + } + if ($declaration === 'float' || $declaration === 'double') { + return self::float(); + } + if ( + in_array($declaration, [ + 'DateTime', + '\DateTime', + 'DateTimeImmutable', + '\DateTimeImmutable', + 'DateTimeInterface', + '\DateTimeInterface' + ]) + ) { + return self::date(); + } + if ($declaration === 'Uri' || $declaration === Uri::class || $declaration === UriInterface::class) { + $declaration = Uri::class; + } + $className = $declaration[0] != '\\' + ? '\\' . $declaration + : $declaration; + if ( + $declaration !== self::TYPE_FLOAT + && $declaration !== self::TYPE_STRING + && $declaration !== self::TYPE_ARRAY + && !class_exists($className) + && !interface_exists($className) + && !preg_match(self::PATTERN_ARRAY_OF, $declaration) + ) { + return null; + } + return new self($declaration); + } + + public static function bool(): self + { + return new self(self::TYPE_BOOL); + } + + public static function int(): self + { + return new self(self::TYPE_INT); + } + + public static function string(): self + { + return new self(self::TYPE_STRING); + } + + public static function float(): self + { + return new self(self::TYPE_FLOAT); + } + + public static function date(): self + { + return new self(self::TYPE_DATE); + } + + public function isBool(): bool + { + return $this->value === self::TYPE_BOOL; + } + + public function isInt(): bool + { + return $this->value === self::TYPE_INT; + } + + public function isFloat(): bool + { + return $this->value === self::TYPE_FLOAT; + } + + public function isString(): bool + { + return $this->value === self::TYPE_STRING; + } + + public function isArray(): bool + { + return $this->value === self::TYPE_ARRAY; + } + + public function isArrayOf(): bool + { + return (bool)preg_match(self::PATTERN_ARRAY_OF, $this->value); + } + + public function isDate(): bool + { + return $this->value === self::TYPE_DATE; + } + + public function getValue(): string + { + return $this->value; + } + + private function getArrayOf(): string + { + return \mb_substr($this->value, 6, -1); + } + + public function isMatchedBy($propertyValue): bool + { + if (is_null($propertyValue)) { + return true; + } + if ($this->isBool()) { + return is_bool($propertyValue); + } + if ($this->isInt()) { + return is_int($propertyValue); + } + if ($this->isFloat()) { + return is_float($propertyValue); + } + if ($this->isString()) { + return is_string($propertyValue); + } + if ($this->isArray()) { + return is_array($propertyValue); + } + if ($this->isDate()) { + return $propertyValue instanceof \DateTimeInterface; + } + if ($this->isArrayOf()) { + if (!is_array($propertyValue)) { + return false; + } + foreach ($propertyValue as $value) { + if (!$this->arrayOfType->isMatchedBy($value)) { + return false; + } + } + return true; + } + + $className = $this->value[0] != '\\' + ? '\\' . $this->value + : $this->value; + + return (class_exists($className) || interface_exists($className)) && $propertyValue instanceof $className; + } +} diff --git a/Classes/Domain/NodeCreation/ReferenceType.php b/Classes/Domain/NodeCreation/ReferenceType.php new file mode 100644 index 0000000..85d9d8a --- /dev/null +++ b/Classes/Domain/NodeCreation/ReferenceType.php @@ -0,0 +1,96 @@ +value = $value; + } + + public static function fromPropertyOfNodeType( + string $propertyName, + NodeType $nodeType + ): self { + $declaration = $nodeType->getPropertyType($propertyName); + if ($declaration === 'reference') { + return self::reference(); + } + if ($declaration === 'references') { + return self::references(); + } + throw new \DomainException( + sprintf( + 'Given property "%s" is not declared as "reference" in node type "%s" and must be treated as such.', + $propertyName, + $nodeType->getName() + ), + 1685964955964 + ); + } + + public static function reference(): self + { + return new self(self::TYPE_REFERENCE); + } + + public static function references(): self + { + return new self(self::TYPE_REFERENCES); + } + + public function isReference(): bool + { + return $this->value === self::TYPE_REFERENCE; + } + + public function isReferences(): bool + { + return $this->value === self::TYPE_REFERENCES; + } + + public function getValue(): string + { + return $this->value; + } + + public function isMatchedBy($propertyValue, Context $subgraphForResolving): bool + { + if ($propertyValue === null) { + return true; + } + $nodeAggregatesOrIds = $this->isReference() ? [$propertyValue] : $propertyValue; + if (is_array($nodeAggregatesOrIds) === false) { + return false; + } + foreach ($nodeAggregatesOrIds as $singleNodeAggregateOrId) { + if ($singleNodeAggregateOrId instanceof NodeInterface) { + continue; + } + if (is_string($singleNodeAggregateOrId) && $subgraphForResolving->getNodeByIdentifier($singleNodeAggregateOrId) instanceof NodeInterface) { + continue; + } + return false; + } + return true; + } +} diff --git a/Classes/NodeTemplateDumper/Comment.php b/Classes/Domain/NodeTemplateDumper/Comment.php similarity index 66% rename from Classes/NodeTemplateDumper/Comment.php rename to Classes/Domain/NodeTemplateDumper/Comment.php index 6b9bc94..6eb1548 100644 --- a/Classes/NodeTemplateDumper/Comment.php +++ b/Classes/Domain/NodeTemplateDumper/Comment.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flowpack\NodeTemplates\NodeTemplateDumper; +namespace Flowpack\NodeTemplates\Domain\NodeTemplateDumper; use Neos\Flow\Annotations as Flow; @@ -14,15 +14,21 @@ */ class Comment { + /** + * @psalm-var \Closure(string $indentation, string $propertyName): string $renderFunction + */ private \Closure $renderFunction; + /** + * @psalm-param \Closure(string $indentation, string $propertyName): string $renderFunction + */ private function __construct(\Closure $renderFunction) { $this->renderFunction = $renderFunction; } /** - * @psalm-param callable(string $indentation, string $propertyName): string $renderFunction + * @psalm-param \Closure(string $indentation, string $propertyName): string $renderFunction */ public static function fromRenderer($renderFunction): self { diff --git a/Classes/NodeTemplateDumper/Comments.php b/Classes/Domain/NodeTemplateDumper/Comments.php similarity index 96% rename from Classes/NodeTemplateDumper/Comments.php rename to Classes/Domain/NodeTemplateDumper/Comments.php index bc39ff5..bf18141 100644 --- a/Classes/NodeTemplateDumper/Comments.php +++ b/Classes/Domain/NodeTemplateDumper/Comments.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flowpack\NodeTemplates\NodeTemplateDumper; +namespace Flowpack\NodeTemplates\Domain\NodeTemplateDumper; use Neos\Flow\Annotations as Flow; use Neos\Flow\Utility\Algorithms; diff --git a/Classes/NodeTemplateDumper/NodeTemplateDumper.php b/Classes/Domain/NodeTemplateDumper/NodeTemplateDumper.php similarity index 97% rename from Classes/NodeTemplateDumper/NodeTemplateDumper.php rename to Classes/Domain/NodeTemplateDumper/NodeTemplateDumper.php index d8751c9..437e20b 100644 --- a/Classes/NodeTemplateDumper/NodeTemplateDumper.php +++ b/Classes/Domain/NodeTemplateDumper/NodeTemplateDumper.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flowpack\NodeTemplates\NodeTemplateDumper; +namespace Flowpack\NodeTemplates\Domain\NodeTemplateDumper; use Neos\ContentRepository\Domain\Model\ArrayPropertyCollection; use Neos\ContentRepository\Domain\Model\NodeInterface; @@ -80,9 +80,7 @@ private function nodeTemplateFromNodes(array $nodes, Comments $comments): array $templatePart = array_filter([ 'properties' => $this->nonDefaultConfiguredNodeProperties($node, $comments), 'childNodes' => $this->nodeTemplateFromNodes( - $isDocumentNode - ? $node->getChildNodes('Neos.Neos:Content,Neos.Neos:ContentCollection,Neos.Neos:Document') - : $node->getChildNodes('Neos.Neos:Content,Neos.Neos:ContentCollection'), + $node->getChildNodes('Neos.Neos:Node'), $comments ) ]); diff --git a/Classes/Domain/Template/RootTemplate.php b/Classes/Domain/Template/RootTemplate.php new file mode 100644 index 0000000..f030e05 --- /dev/null +++ b/Classes/Domain/Template/RootTemplate.php @@ -0,0 +1,57 @@ + + */ + private array $properties; + + private Templates $childNodes; + + /** + * @internal + * @param array $properties + */ + public function __construct(array $properties, Templates $childNodes) + { + $this->properties = $properties; + $this->childNodes = $childNodes; + } + + public static function empty(): self + { + return new RootTemplate([], Templates::empty()); + } + + /** + * @return array + */ + public function getProperties(): array + { + return $this->properties; + } + + public function getChildNodes(): Templates + { + return $this->childNodes; + } + + public function jsonSerialize() + { + return [ + 'properties' => $this->properties, + 'childNodes' => $this->childNodes + ]; + } +} diff --git a/Classes/Domain/Template/Template.php b/Classes/Domain/Template/Template.php new file mode 100644 index 0000000..b9715ab --- /dev/null +++ b/Classes/Domain/Template/Template.php @@ -0,0 +1,68 @@ + + */ + private array $properties; + + private Templates $childNodes; + + /** + * @internal + * @param array $properties + */ + public function __construct(?NodeTypeName $type, ?NodeName $name, array $properties, Templates $childNodes) + { + $this->type = $type; + $this->name = $name; + $this->properties = $properties; + $this->childNodes = $childNodes; + } + + public function getType(): ?NodeTypeName + { + return $this->type; + } + + public function getName(): ?NodeName + { + return $this->name; + } + + /** + * @return array + */ + public function getProperties(): array + { + return $this->properties; + } + + public function getChildNodes(): Templates + { + return $this->childNodes; + } + + public function jsonSerialize() + { + return [ + 'type' => $this->type, + 'name' => $this->name, + 'properties' => $this->properties, + 'childNodes' => $this->childNodes + ]; + } +} diff --git a/Classes/Domain/Template/Templates.php b/Classes/Domain/Template/Templates.php new file mode 100644 index 0000000..b03b676 --- /dev/null +++ b/Classes/Domain/Template/Templates.php @@ -0,0 +1,60 @@ + */ + private array $items; + + public function __construct( + Template ...$items + ) { + $this->items = $items; + } + + public static function empty(): self + { + return new self(); + } + + /** + * @return \Traversable|Template[] + */ + public function getIterator(): \Traversable + { + yield from $this->items; + } + + public function withAdded(Template $template): self + { + return new self(...$this->items, ...[$template]); + } + + public function merge(Templates $other): self + { + return new self(...$this->items, ...$other->items); + } + + public function toRootTemplate(): RootTemplate + { + if (count($this->items) > 1) { + throw new \BadMethodCallException('Templates cannot be transformed to RootTemplate because it holds multiple Templates.', 1685866910655); + } + foreach ($this->items as $first) { + return new RootTemplate( + $first->getProperties(), + $first->getChildNodes() + ); + } + return RootTemplate::empty(); + } + + public function jsonSerialize() + { + return $this->items; + } +} diff --git a/Classes/Service/EelEvaluationService.php b/Classes/Domain/TemplateConfiguration/EelEvaluationService.php similarity index 52% rename from Classes/Service/EelEvaluationService.php rename to Classes/Domain/TemplateConfiguration/EelEvaluationService.php index d99404f..542c6f4 100644 --- a/Classes/Service/EelEvaluationService.php +++ b/Classes/Domain/TemplateConfiguration/EelEvaluationService.php @@ -1,11 +1,11 @@ additional context for eel expressions * @return mixed The result of the evaluated Eel expression - * @throws \Neos\Eel\Exception + * @throws ParserException|\Exception */ public function evaluateEelExpression(string $expression, array $contextVariables) { if ($this->defaultContextVariables === null) { - $this->defaultContextVariables = EelUtility::getDefaultContextVariables($this->defaultContext); + $this->defaultContextVariables = EelUtility::getDefaultContextVariables($this->defaultContextConfiguration); } $contextVariables = array_merge($this->defaultContextVariables, $contextVariables); - try { - return EelUtility::evaluateEelExpression($expression, $this->eelEvaluator, $contextVariables); - } catch (ParserException $parserException) { - throw new EelException('EEL Expression in NodeType template could not be parsed.', 1684788574212, $parserException); - } catch (\Exception $exception) { - throw new EelException(sprintf('EEL Expression "%s" in NodeType template caused an error.', $expression), 1684761760723, $exception); - } + return EelUtility::evaluateEelExpression($expression, $this->eelEvaluator, $contextVariables); } } diff --git a/Classes/Domain/TemplateConfiguration/StopBuildingTemplatePartException.php b/Classes/Domain/TemplateConfiguration/StopBuildingTemplatePartException.php new file mode 100644 index 0000000..13ef8d8 --- /dev/null +++ b/Classes/Domain/TemplateConfiguration/StopBuildingTemplatePartException.php @@ -0,0 +1,8 @@ + $configuration + * @psalm-param array $evaluationContext + * @param CaughtExceptions $caughtEvaluationExceptions + * @return RootTemplate + */ + public function processTemplateConfiguration(array $configuration, array $evaluationContext, CaughtExceptions $caughtEvaluationExceptions): RootTemplate + { + try { + $templatePart = TemplatePart::createRoot( + $configuration, + $evaluationContext, + fn ($value, $evaluationContext) => $this->preprocessConfigurationValue($value, $evaluationContext), + $caughtEvaluationExceptions + ); + } catch (StopBuildingTemplatePartException $e) { + return RootTemplate::empty(); + } + return $this->createTemplatesFromTemplatePart($templatePart)->toRootTemplate(); + } + + private function createTemplatesFromTemplatePart(TemplatePart $templatePart): Templates + { + try { + $withContext = []; + foreach ($templatePart->getRawConfiguration('withContext') ?? [] as $key => $_) { + $withContext[$key] = $templatePart->processConfiguration(['withContext', $key]); + } + $templatePart = $templatePart->withMergedEvaluationContext($withContext); + + if ($templatePart->hasConfiguration('when') && !$templatePart->processConfiguration('when')) { + return Templates::empty(); + } + + if (!$templatePart->hasConfiguration('withItems')) { + return new Templates($this->createTemplateFromTemplatePart($templatePart)); + } + $items = $templatePart->processConfiguration('withItems'); + + if (!is_iterable($items)) { + $templatePart->getCaughtExceptions()->add( + CaughtException::fromException( + new \RuntimeException(sprintf('Type %s is not iterable.', gettype($items)), 1685802354186) + )->withOrigin(sprintf('Configuration "%s" in "%s"', json_encode($templatePart->getRawConfiguration('withItems')), join('.', array_merge($templatePart->getFullPathToConfiguration(), ['withItems'])))) + ); + return Templates::empty(); + } + + $templates = Templates::empty(); + foreach ($items as $itemKey => $itemValue) { + $itemTemplatePart = $templatePart->withMergedEvaluationContext([ + 'item' => $itemValue, + 'key' => $itemKey + ]); + + try { + $templates = $templates->withAdded($this->createTemplateFromTemplatePart($itemTemplatePart)); + } catch (StopBuildingTemplatePartException $e) { + } + } + return $templates; + } catch (StopBuildingTemplatePartException $e) { + return Templates::empty(); + } + } + + private function createTemplateFromTemplatePart(TemplatePart $templatePart): Template + { + // process the properties + $processedProperties = []; + foreach ($templatePart->getRawConfiguration('properties') ?? [] as $propertyName => $value) { + if (!is_scalar($value) && !is_null($value)) { + $templatePart->getCaughtExceptions()->add(CaughtException::fromException( + new \RuntimeException(sprintf('Template configuration properties can only hold int|float|string|bool|null. Property "%s" has type "%s"', $propertyName, gettype($value)), 1685725310730) + )); + continue; + } + try { + $processedProperties[$propertyName] = $templatePart->processConfiguration(['properties', $propertyName]); + } catch (StopBuildingTemplatePartException $e) { + } + } + + // process the childNodes + $childNodeTemplates = Templates::empty(); + foreach ($templatePart->getRawConfiguration('childNodes') ?? [] as $childNodeConfigurationPath => $_) { + try { + $childNodeTemplatePart = $templatePart->withConfigurationByConfigurationPath(['childNodes', $childNodeConfigurationPath]); + } catch (StopBuildingTemplatePartException $e) { + continue; + } + $childNodeTemplates = $childNodeTemplates->merge($this->createTemplatesFromTemplatePart($childNodeTemplatePart)); + } + + $type = $templatePart->processConfiguration('type'); + $name = $templatePart->processConfiguration('name'); + return new Template( + $type ? NodeTypeName::fromString($type) : null, + $name ? NodeName::fromString($name) : null, + $processedProperties, + $childNodeTemplates + ); + } + + /** + * @psalm-param mixed $rawConfigurationValue + * @psalm-param array $evaluationContext + * @psalm-return mixed + * @throws \Neos\Eel\ParserException|\Exception + */ + private function preprocessConfigurationValue($rawConfigurationValue, array $evaluationContext) + { + if (!is_string($rawConfigurationValue)) { + return $rawConfigurationValue; + } + if (strpos($rawConfigurationValue, '${') !== 0) { + return $rawConfigurationValue; + } + return $this->eelEvaluationService->evaluateEelExpression($rawConfigurationValue, $evaluationContext); + } +} diff --git a/Classes/Domain/TemplateConfiguration/TemplatePart.php b/Classes/Domain/TemplateConfiguration/TemplatePart.php new file mode 100644 index 0000000..e66bce0 --- /dev/null +++ b/Classes/Domain/TemplateConfiguration/TemplatePart.php @@ -0,0 +1,217 @@ + $evaluationContext): mixed + */ + private \Closure $configurationValueProcessor; + + /** + * @psalm-readonly + */ + private CaughtExceptions $caughtExceptions; + + /** + * @psalm-param array $configuration + * @psalm-param array $evaluationContext + * @psalm-param \Closure(mixed $value, array $evaluationContext): mixed $configurationValueProcessor + * @throws StopBuildingTemplatePartException + */ + private function __construct( + array $configuration, + array $fullPathToConfiguration, + array $evaluationContext, + \Closure $configurationValueProcessor, + CaughtExceptions $caughtExceptions + ) { + $this->configuration = $configuration; + $this->fullPathToConfiguration = $fullPathToConfiguration; + $this->evaluationContext = $evaluationContext; + $this->configurationValueProcessor = $configurationValueProcessor; + $this->caughtExceptions = $caughtExceptions; + $this->validateTemplateConfigurationKeys(); + } + + /** + * @psalm-param array $configuration + * @psalm-param array $evaluationContext + * @psalm-param \Closure(mixed $value, array $evaluationContext): mixed $configurationValueProcessor + * @throws StopBuildingTemplatePartException + */ + public static function createRoot( + array $configuration, + array $evaluationContext, + \Closure $configurationValueProcessor, + CaughtExceptions $caughtExceptions + ): self { + return new self( + $configuration, + [], + $evaluationContext, + $configurationValueProcessor, + $caughtExceptions + ); + } + + public function getCaughtExceptions(): CaughtExceptions + { + return $this->caughtExceptions; + } + + public function getFullPathToConfiguration(): array + { + return $this->fullPathToConfiguration; + } + + /** + * @psalm-param string|list $configurationPath + * @throws StopBuildingTemplatePartException + */ + public function withConfigurationByConfigurationPath($configurationPath): self + { + return new self( + $this->getRawConfiguration($configurationPath), + array_merge($this->fullPathToConfiguration, $configurationPath), + $this->evaluationContext, + $this->configurationValueProcessor, + $this->caughtExceptions + ); + } + + /** + * @psalm-param array $evaluationContext + */ + public function withMergedEvaluationContext(array $evaluationContext): self + { + if ($evaluationContext === []) { + return $this; + } + return new self( + $this->configuration, + $this->fullPathToConfiguration, + array_merge($this->evaluationContext, $evaluationContext), + $this->configurationValueProcessor, + $this->caughtExceptions + ); + } + + /** + * @psalm-param string|list $configurationPath + * @return mixed + * @throws StopBuildingTemplatePartException + */ + public function processConfiguration($configurationPath) + { + if (($value = $this->getRawConfiguration($configurationPath)) === null) { + return null; + } + try { + return ($this->configurationValueProcessor)($value, $this->evaluationContext); + } catch (\Throwable $exception) { + $fullConfigurationPath = array_merge( + $this->fullPathToConfiguration, + is_array($configurationPath) ? $configurationPath : [$configurationPath] + ); + $this->caughtExceptions->add( + CaughtException::fromException($exception)->withOrigin( + sprintf( + 'Expression "%s" in "%s"', + $value, + join('.', $fullConfigurationPath) + ) + ) + ); + throw new StopBuildingTemplatePartException(); + } + } + + /** + * Minimal implementation of {@see \Neos\Utility\Arrays::getValueByPath()} (but we dont allow $configurationPath to contain dots.) + * + * @psalm-param string|list $configurationPath + */ + public function getRawConfiguration($configurationPath) + { + assert(is_array($configurationPath) || is_string($configurationPath)); + $path = is_array($configurationPath) ? $configurationPath : [$configurationPath]; + $array = $this->configuration; + foreach ($path as $key) { + if (is_array($array) && array_key_exists($key, $array)) { + $array = $array[$key]; + } else { + return null; + } + } + return $array; + } + + /** + * @psalm-param string|list $configurationPath + */ + public function hasConfiguration($configurationPath): bool + { + assert(is_array($configurationPath) || is_string($configurationPath)); + $path = is_array($configurationPath) ? $configurationPath : [$configurationPath]; + $array = $this->configuration; + foreach ($path as $key) { + if (is_array($array) && array_key_exists($key, $array)) { + $array = $array[$key]; + } else { + return false; + } + } + return true; + } + + /** + * @throws StopBuildingTemplatePartException + */ + private function validateTemplateConfigurationKeys(): void + { + $isRootTemplate = $this->fullPathToConfiguration === []; + foreach (array_keys($this->configuration) as $key) { + if (!in_array($key, ['type', 'name', 'properties', 'childNodes', 'when', 'withItems', 'withContext'], true)) { + $this->caughtExceptions->add( + CaughtException::fromException(new \InvalidArgumentException(sprintf('Template configuration has illegal key "%s"', $key), 1686150349274)) + ); + throw new StopBuildingTemplatePartException(); + } + if ($isRootTemplate) { + if (!in_array($key, ['properties', 'childNodes', 'when', 'withContext'], true)) { + $this->caughtExceptions->add( + CaughtException::fromException(new \InvalidArgumentException(sprintf('Root template configuration doesnt allow option "%s', $key), 1686150340657)) + ); + throw new StopBuildingTemplatePartException(); + } + } + } + } +} diff --git a/Classes/Domain/TemplateNodeCreationHandler.php b/Classes/Domain/TemplateNodeCreationHandler.php new file mode 100644 index 0000000..55f1573 --- /dev/null +++ b/Classes/Domain/TemplateNodeCreationHandler.php @@ -0,0 +1,64 @@ +getNodeType()->hasConfiguration('options.template')) { + return; + } + + $evaluationContext = [ + 'data' => $data, + 'triggeringNode' => $node, + ]; + + $templateConfiguration = $node->getNodeType()->getConfiguration('options.template'); + + $caughtExceptions = CaughtExceptions::create(); + try { + $template = $this->templateConfigurationProcessor->processTemplateConfiguration($templateConfiguration, $evaluationContext, $caughtExceptions); + $this->exceptionHandler->handleAfterTemplateConfigurationProcessing($caughtExceptions, $node); + + $this->nodeCreationService->apply($template, $node, $caughtExceptions); + $this->exceptionHandler->handleAfterNodeCreation($caughtExceptions, $node); + } catch (TemplateNotCreatedException|TemplatePartiallyCreatedException $templateCreationException) { + } + } +} diff --git a/Classes/NodeCreationHandler/TemplateNodeCreationHandler.php b/Classes/NodeCreationHandler/TemplateNodeCreationHandler.php deleted file mode 100644 index 584ccfb..0000000 --- a/Classes/NodeCreationHandler/TemplateNodeCreationHandler.php +++ /dev/null @@ -1,129 +0,0 @@ -getNodeType()->hasConfiguration('options.template')) { - $templateConfiguration = $node->getNodeType()->getConfiguration('options.template'); - } else { - return; - } - - $propertyMappingConfiguration = $this->propertyMapper->buildPropertyMappingConfiguration(); - - $subPropertyMappingConfiguration = $propertyMappingConfiguration; - for ($i = 0; $i < $this->nodeCreationDepth; $i++) { - $subPropertyMappingConfiguration = $subPropertyMappingConfiguration - ->forProperty('childNodes.*') - ->allowAllProperties(); - } - - /** @var Template $template */ - $template = $this->propertyMapper->convert( - $templateConfiguration, - Template::class, - $propertyMappingConfiguration - ); - - $context = [ - 'data' => $data, - 'triggeringNode' => $node, - ]; - - try { - $template->apply($node, $context); - } catch (\Exception $exception) { - $this->handleExceptions($node, $exception); - } - } - - /** - * Known exceptions are logged to the Neos.Ui and caught - * - * @param NodeInterface $node the newly created node - * @param \Exception $exception the exception to handle - * @throws \Exception in case the exception is unknown and cant be handled - */ - private function handleExceptions(NodeInterface $node, \Exception $exception): void - { - $nodeTemplateError = new Error(); - $nodeTemplateError->setMessage(sprintf('Template for "%s" only partially applied. Please check the newly created nodes.', $node->getNodeType()->getLabel())); - - if ($exception instanceof NodeConstraintException) { - $this->feedbackCollection->add( - $nodeTemplateError - ); - - $error = new Error(); - $error->setMessage($exception->getMessage()); - $this->feedbackCollection->add( - $error - ); - return; - } - if ($exception instanceof EelException) { - $this->feedbackCollection->add( - $nodeTemplateError - ); - - $error = new Error(); - $error->setMessage( - $exception->getMessage() - ); - $this->feedbackCollection->add( - $error - ); - - $level = 0; - while (($exception = $exception->getPrevious()) && $level <= 8) { - $level++; - $error = new Error(); - $error->setMessage( - sprintf('%s [%s(%s)]', $exception->getMessage(), get_class($exception), $exception->getCode()) - ); - $this->feedbackCollection->add( - $error - ); - } - return; - } - throw $exception; - } -} diff --git a/Classes/NodeCreationHandler/TemplatingDocumentTitleNodeCreationHandler.php b/Classes/NodeCreationHandler/TemplatingDocumentTitleNodeCreationHandler.php deleted file mode 100644 index ef4e5c2..0000000 --- a/Classes/NodeCreationHandler/TemplatingDocumentTitleNodeCreationHandler.php +++ /dev/null @@ -1,76 +0,0 @@ -getNodeType()->isOfType('Neos.Neos:Document')) { - return; - } - - $titleTemplate = $node->getNodeType()->getOptions()['template']['properties']['title'] ?? ''; - - if ($titleTemplate === '') { - $title = $data['title'] ?? null; - } else { - if (preg_match(Package::EelExpressionRecognizer, $titleTemplate)) { - $context = [ - 'data' => $data, - 'triggeringNode' => $node, - ]; - - $title = $this->eelEvaluationService->evaluateEelExpression($titleTemplate, $context); - } - } - - $uriPathSegmentTemplate = $node->getNodeType()->getOptions()['template']['properties']['uriPathSegment'] ?? ''; - if ($uriPathSegmentTemplate === '') { - $uriPathSegment = $data['uriPathSegment'] ?? null; - } else { - if (preg_match(Package::EelExpressionRecognizer, $uriPathSegmentTemplate)) { - $context = [ - 'data' => $data, - 'triggeringNode' => $node, - ]; - - $uriPathSegment = $this->eelEvaluationService->evaluateEelExpression($uriPathSegmentTemplate, $context); - } - } - - if (!isset($uriPathSegment) || $uriPathSegment === '') { - $uriPathSegment = $title; - } - - $node->setProperty('title', (string) $title); - $node->setProperty('uriPathSegment', $this->nodeUriPathSegmentGenerator->generateUriPathSegment($node, $uriPathSegment)); - } -} diff --git a/Classes/Service/EelException.php b/Classes/Service/EelException.php deleted file mode 100644 index 5c36816..0000000 --- a/Classes/Service/EelException.php +++ /dev/null @@ -1,7 +0,0 @@ - - */ - protected $childNodes; - - /** - * Options can be used to configure third party processing - * - * @var array - */ - protected $options; - - /** - * @var string - */ - protected $when; - - /** - * @var string - */ - protected $withItems; - - /** - * @var array - */ - protected $withContext; - - /** - * @var EelEvaluationService - * @Flow\Inject - */ - protected $eelEvaluationService; - - /** - * @var NodeOperations - * @Flow\Inject - */ - protected $nodeOperations; - - /** - * @var PersistenceManager - * @Flow\Inject - */ - protected $persistenceManager; - - /** - * Template constructor - * - * @param string $type - * @param string $name - * @param array $properties - * @param array