diff --git a/composer.json b/composer.json index 60f3cd0..0298823 100644 --- a/composer.json +++ b/composer.json @@ -10,15 +10,17 @@ } ], "require": { - "php": ">= 8.1", + "php": ">= 8.2", "21torr/bundle-helpers": "^2.1.2", "21torr/cli": "^1.0", "psr/log": "^3.0", + "symfony/cache-contracts": "^3.3", "symfony/console": "^6.1", "symfony/http-client": "^6.1", "symfony/lock": "^6.2", + "symfony/property-access": "^6.3", "symfony/rate-limiter": "^6.2", - "symfony/string": "^6.2", + "symfony/string": "^6.3", "symfony/validator": "^6.1" }, "require-dev": { diff --git a/config/services.yaml b/config/services.yaml index 244533d..8df91c1 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -19,6 +19,5 @@ services: $managementToken: !abstract set via config $contentToken: !abstract set via config - - Torr\Storyblok\Manager\ComponentManager: - $components: !tagged_locator { tag: 'storyblok.component.definition', default_index_method: 'getKey' } + Torr\Storyblok\Cache\DebugCacheFactory: + $isDebug: '%kernel.debug%' diff --git a/src/Api/ContentApi.php b/src/Api/ContentApi.php index 93e9440..5a43a61 100644 --- a/src/Api/ContentApi.php +++ b/src/Api/ContentApi.php @@ -16,8 +16,8 @@ use Torr\Storyblok\Exception\Story\InvalidDataException; use Torr\Storyblok\Manager\ComponentManager; use Torr\Storyblok\Release\ReleaseVersion; -use Torr\Storyblok\Story\Story; -use Torr\Storyblok\Story\StoryFactory; +use Torr\Storyblok\Story\StandaloneNestedStory; +use Torr\Storyblok\Hydrator\StoryHydrator; final class ContentApi implements ResetInterface { @@ -31,7 +31,7 @@ final class ContentApi implements ResetInterface public function __construct ( HttpClientInterface $client, private readonly StoryblokConfig $config, - private readonly StoryFactory $storyFactory, + private readonly StoryHydrator $storyFactory, private readonly ComponentManager $componentManager, private readonly LoggerInterface $logger, ) @@ -54,7 +54,7 @@ public function __construct ( public function fetchSingleStory ( string|int $identifier, ReleaseVersion $version = ReleaseVersion::PUBLISHED, - ) : ?Story + ) : ?StandaloneNestedStory { try { @@ -102,14 +102,14 @@ public function fetchSingleStory ( * This method provides certain commonly used named parameters, but also supports passing arbitrary parameters * in the parameter. Passing named parameters will always overwrite parameters in $query. * - * @template TStory of Story + * @template TStory of StandaloneNestedStory * * @param class-string $storyType * - * @throws ContentRequestFailedException - * @throws UnknownStoryTypeException - * * @return array + *@throws UnknownStoryTypeException + * + * @throws ContentRequestFailedException */ public function fetchStories ( string $storyType, @@ -154,9 +154,9 @@ public function fetchStories ( * * @param string|string[]|null $slug * - * @throws ContentRequestFailedException + * @return array + *@throws ContentRequestFailedException * - * @return array */ public function fetchAllStories ( string|array|null $slug, diff --git a/src/Api/Data/PaginatedApiResult.php b/src/Api/Data/PaginatedApiResult.php index 551e86b..4f5aa86 100644 --- a/src/Api/Data/PaginatedApiResult.php +++ b/src/Api/Data/PaginatedApiResult.php @@ -2,12 +2,12 @@ namespace Torr\Storyblok\Api\Data; -use Torr\Storyblok\Story\Story; +use Torr\Storyblok\Story\StandaloneNestedStory; final class PaginatedApiResult { /** - * @param array $stories + * @param array $stories */ public function __construct ( public readonly int $perPage, diff --git a/src/Api/ManagementApi.php b/src/Api/ManagementApi.php index 84b0d1f..f8ccfe6 100644 --- a/src/Api/ManagementApi.php +++ b/src/Api/ManagementApi.php @@ -12,7 +12,7 @@ use Torr\Storyblok\Api\Data\ComponentIdMap; use Torr\Storyblok\Config\StoryblokConfig; use Torr\Storyblok\Exception\Api\ApiRequestFailedException; -use Torr\Storyblok\Folder\FolderData; +use Torr\Storyblok\Data\Folder\FolderData; final class ManagementApi { diff --git a/src/Cache/DebugCache.php b/src/Cache/DebugCache.php new file mode 100644 index 0000000..4241fdd --- /dev/null +++ b/src/Cache/DebugCache.php @@ -0,0 +1,46 @@ +itemGenerator = $itemGenerator; + } + + + /** + * @return DataType + */ + public function get () : mixed + { + if ($this->isDebug) + { + if ($this->hasLocaleCache) + { + return $this->localCache; + } + + $this->hasLocaleCache = true; + return $this->localCache = ($this->itemGenerator)(); + } + + return $this->cache->get($this->cacheKey, $this->itemGenerator); + } +} diff --git a/src/Cache/DebugCacheFactory.php b/src/Cache/DebugCacheFactory.php new file mode 100644 index 0000000..2cd1c1c --- /dev/null +++ b/src/Cache/DebugCacheFactory.php @@ -0,0 +1,32 @@ +cache, + $cacheKey, + $itemGenerator, + $this->isDebug, + ); + } +} diff --git a/src/Command/DebugCommand.php b/src/Command/DebugCommand.php new file mode 100644 index 0000000..e88b69f --- /dev/null +++ b/src/Command/DebugCommand.php @@ -0,0 +1,67 @@ +addOption("fetch") + ->addOption("sync"); + } + + + /** + * @inheritDoc + */ + protected function execute (InputInterface $input, OutputInterface $output) : int + { + $io = new TorrStyle($input, $output); + $io->title("Storyblok: Debug"); + + if ($input->getOption("fetch")) + { + $stories = $this->contentApi->fetchAllStories(null, version: ReleaseVersion::DRAFT); + dd($stories); + } + + $definitions = $this->componentManager->getDefinitions(); + dump($definitions); + + if ($input->getOption("sync")) + { + foreach ($definitions->getComponents() as $component) + { + $io->section($component->definition->name); + dump($component->generateManagementApiData()); + } + } + + return self::SUCCESS; + } +} diff --git a/src/Command/ValidateDefinitionsCommand.php b/src/Command/ValidateDefinitionsCommand.php deleted file mode 100644 index e903451..0000000 --- a/src/Command/ValidateDefinitionsCommand.php +++ /dev/null @@ -1,62 +0,0 @@ -title("Storyblok: Sync Definitions"); - - $spaceInfo = $this->contentApi->getSpaceInfo(); - - $io->comment(\sprintf( - "Validating components for space %s (%d)\n%s", - $spaceInfo->getName(), - $spaceInfo->getId(), - $spaceInfo->getBackendDashboardUrl(), - )); - - try - { - $this->componentValidator->validateDefinitions($io); - - $io->newLine(2); - $io->success("All definitions validated."); - - return self::SUCCESS; - } - catch (ValidationFailedException $exception) - { - $io->comment(\sprintf("ERROR\n%s", $exception->getMessage())); - $io->error("Definitions validation failed"); - - return self::FAILURE; - } - } -} diff --git a/src/Component/AbstractComponent.php b/src/Component/AbstractComponent.php deleted file mode 100644 index 96130b8..0000000 --- a/src/Component/AbstractComponent.php +++ /dev/null @@ -1,291 +0,0 @@ - - */ - abstract protected function configureFields () : array; - - /** - * Returns the type of this component - */ - abstract protected function getComponentType () : ComponentType; - - /** - * Returns the human-readable name of this component - */ - abstract public function getDisplayName () : string; - - /** - * Returns the component group display name - */ - public function getComponentGroup () : string|\BackedEnum|null - { - return null; - } - - /** - * Returns the tags of this component - * - * @return array - */ - public function getTags () : array - { - return []; - } - - /** - * Returns the class of story to create for this component. - * If null is returned here, you can't create a story for that component - * - * @return class-string|null - */ - public function getStoryClass () : ?string - { - return null; - } - - /** - * Receives the Storyblok data for the given field and transforms it for better usage - */ - public function transformData ( - array $data, - ComponentContext $dataContext, - ?DataVisitorInterface $dataVisitor = null, - ) : ComponentData - { - $transformedData = []; - - foreach ($this->getFields()->getRootFields() as $fieldName => $field) - { - $transformedFieldData = $field->transformData( - $data[$fieldName] ?? null, - $dataContext, - $data, - $dataVisitor, - ); - - if ($transformedFieldData instanceof InlinedTransformedData) - { - $transformedData = [ - ...$transformedData, - ...$transformedFieldData->data, - ]; - } - else - { - $transformedData[$fieldName] = $transformedFieldData; - } - } - - $componentData = new ComponentData( - $data["_uid"], - static::getKey(), - $transformedData, - previewData: $data["_editable"] ?? null, - ); - - if ($dataVisitor instanceof ComponentDataVisitorInterface) - { - $dataVisitor->onDataVisit($this, $componentData); - } - - return $componentData; - } - - /** - * Transforms the data of a single field - */ - public function transformField ( - array $data, - string $fieldName, - ComponentContext $dataContext, - ?DataVisitorInterface $dataVisitor = null, - ) : mixed - { - $field = $this->getFields()->getField($fieldName); - - return $field->transformData( - $data[$fieldName] ?? null, - $dataContext, - $data, - $dataVisitor, - ); - } - - - /** - * @throws InvalidDataException - */ - public function validateData ( - ComponentContext $context, - mixed $data, - array $contentPath = [], - ?string $label = null, - ) : void - { - $contentPath = [ - ...$contentPath, - \sprintf("Component(%s, '%s')", static::getKey(), $label ?? "n/a"), - ]; - - // validate base data - $context->validator->ensureDataIsValid( - $contentPath, - $this, - $data, - [ - new NotNull(), - new Type("array"), - new Collection( - fields: [ - "_uid" => [ - new NotNull(), - new Type("string"), - ], - "component" => [ - new NotNull(), - new Type("string"), - ], - ], - allowExtraFields: true, - allowMissingFields: false, - ), - ], - ); - - \assert(\is_array($data)); - - foreach ($this->getFields()->getRootFields() as $name => $field) - { - $fieldData = $data[$name] ?? null; - - $field->validateData( - $context, - [ - ...$contentPath, - \sprintf("Field(%s)", $name), - ], - $fieldData, - $data, - ); - } - } - - final protected function getFields () : FieldCollection - { - return $this->fields ??= new FieldCollection($this->configureFields()); - } - - - /** - * Normalizes the fields for usage in the management API - * - * @param array $fields - */ - private function normalizeFields ( - array $fields, - ) : array - { - if (empty($fields)) - { - throw new InvalidComponentConfigurationException(\sprintf( - "Invalid component '%s': can't have a component without fields", - static::class, - )); - } - - $managementDataApi = new ManagementApiData(); - - foreach ($fields as $key => $field) - { - if (ComponentHelper::isReservedKey($key)) - { - throw new InvalidComponentConfigurationException(\sprintf( - "Invalid component configuration '%s': can't use '%s' as field key, as that is a reserved key.", - static::class, - $key, - )); - } - - $field->registerManagementApiData($key, $managementDataApi); - } - - return $managementDataApi->getFullConfig(); - } - - - /** - * Transforms the data for the component - * - * @internal - */ - final public function toManagementApiData () : array - { - if (ComponentHelper::isReservedKey(static::getKey())) - { - throw new InvalidComponentConfigurationException(\sprintf( - "Invalid component configuration '%s': can't use '%s' as component key, as that is a reserved key.", - static::class, - static::getKey(), - )); - } - - $definition = $this->configureComponent(); - - return [ - "name" => static::getKey(), - "display_name" => $this->getDisplayName(), - "schema" => $this->normalizeFields($this->getFields()->getRootFields()), - "image" => $definition->previewScreenshotUrl, - "preview" => $definition->previewFieldName, - "preview_tmpl" => $definition->previewTemplate, - "real_name" => static::getKey(), - "color" => $definition->iconBackgroundColor, - "icon" => $definition->icon?->value, - ...$this->getComponentType()->toManagementApiData(), - ]; - } -} diff --git a/src/Component/ComponentHelper.php b/src/Component/ComponentHelper.php deleted file mode 100644 index f8c1387..0000000 --- a/src/Component/ComponentHelper.php +++ /dev/null @@ -1,14 +0,0 @@ -storyblokIdSlugMapper = $storyblokIdSlugMapper; - } - - /** - * @see DataValidator::ensureDataIsValid() - * - * @param string[] $contentPath - * @param array $constraints - */ - public function ensureDataIsValid ( - array $contentPath, - FieldDefinitionInterface $field, - mixed $data, - array $constraints, - ) : void - { - $this->validator->ensureDataIsValid($contentPath, $field, $data, $constraints); - } - - /** - * @see ComponentManager::getComponent() - */ - public function getComponentByKey (string $key) : AbstractComponent - { - return $this->componentManager->getComponent($key); - } - - /** - * @see DataTransformer::normalizeOptionalString() - */ - public function normalizeOptionalString (?string $value) : ?string - { - return $this->dataTransformer->normalizeOptionalString($value); - } - - /** - * @return array{int|null, int|null} - */ - public function extractImageDimensions (string $imageUrl) : array - { - return $this->imageDimensionsExtractor->extractImageDimensions($imageUrl); - } - - /** - * @see StoryblokIdSlugMapper::getFullSlugById() - */ - public function fetchFullSlugByUuid (string|int $identifier) : string|null - { - // the content api is already set by the DI container - \assert(null !== $this->storyblokIdSlugMapper); - return $this->storyblokIdSlugMapper->getFullSlugById($identifier); - } -} diff --git a/src/Folder/FolderData.php b/src/Data/Folder/FolderData.php similarity index 93% rename from src/Folder/FolderData.php rename to src/Data/Folder/FolderData.php index bdbbe4a..0e013ec 100644 --- a/src/Folder/FolderData.php +++ b/src/Data/Folder/FolderData.php @@ -1,6 +1,6 @@ 0) { + $formattedPath = \implode(" → ", $contentPath); + $formattedViolations = $violations instanceof ConstraintViolationList + ? (string) $violations + : "n/a"; + + $this->logger->error("Storyblok: Invalid data found at {path}", [ + "path" => $formattedPath, + "violations" => $formattedViolations, + "data" => $data, + ]); + throw new InvalidDataException( \sprintf( "Invalid data found at '%s':\n%s", - \implode(" → ", $contentPath), - $violations instanceof ConstraintViolationList - ? (string) $violations - : "n/a", + $formattedPath, + $formattedViolations, ), $contentPath, - $field, $data, $violations, ); diff --git a/src/Definition/Component/ComponentDefinition.php b/src/Definition/Component/ComponentDefinition.php new file mode 100644 index 0000000..e079a03 --- /dev/null +++ b/src/Definition/Component/ComponentDefinition.php @@ -0,0 +1,90 @@ + */ + public string $storyClass, + public bool $isDocument, + /** @type array */ + public array $fields, + ) + { + if (null !== $this->definition->previewField && !\array_key_exists($this->definition->previewField, $this->fields)) + { + throw new InvalidComponentDefinitionException(\sprintf( + "Can't use unknown field '%s' as preview field in story '%s'", + $this->definition->previewField, + $this->storyClass, + )); + } + } + + /** + */ + public function getName () : string + { + return $this->definition->getName(); + } + + /** + */ + public function getKey () : string + { + return $this->definition->key; + } + + + public function generateManagementApiData () : array + { + $fieldSchemas = []; + $position = 0; + + foreach ($this->fields as $field) + { + $additionalFields = $field instanceof FieldDefinition + ? [ + $field->field->key => $field->generateManagementApiData(), + ] + : $field->generateManagementApiDataForAllFields(); + + foreach ($additionalFields as $key => $fieldData) + { + if (\array_key_exists($key, $fieldSchemas)) + { + throw new InvalidComponentDefinitionException(\sprintf( + "Found multiple definitions for field name '%s' in type '%s'", + $key, + $this->storyClass, + )); + } + + // consistently set the position for all fields + $fieldData["pos"] = ++$position; + $fieldSchemas[$key] = $fieldData; + } + } + + return [ + "name" => $this->definition->key, + "real_name" => $this->definition->key, + "display_name" => $this->getName(), + "schema" => $fieldSchemas, + "preview_field" => $this->definition->previewField, + "is_root" => $this->isDocument, + "is_nestable" => !$this->isDocument, + ]; + } +} diff --git a/src/Definition/Component/ComponentDefinitionFactory.php b/src/Definition/Component/ComponentDefinitionFactory.php new file mode 100644 index 0000000..1edf4e2 --- /dev/null +++ b/src/Definition/Component/ComponentDefinitionFactory.php @@ -0,0 +1,198 @@ + $storyblokClass) + { + $result[$key] = $this->createDefinition($storyblokClass); + } + + return new ComponentDefinitionRegistry($result); + } + + + /** + * @param class-string $storyblokClass + */ + private function createDefinition ( + string $storyblokClass, + ) : ComponentDefinition + { + try + { + $reflectionClass = new \ReflectionClass($storyblokClass); + $definition = $this->helper->getRequiredSingleAttribute($reflectionClass, Storyblok::class); + + $isDocument = \is_a($reflectionClass->getName(), StandaloneNestedStory::class, true); + \assert($isDocument || \is_a($reflectionClass->getName(), NestedStory::class, true), "must either be a document or a blok"); + + return new ComponentDefinition( + $definition, + $storyblokClass, + $isDocument, + $this->createFieldDefinitions($reflectionClass), + ); + } + catch (\ReflectionException $exception) + { + throw new InvalidComponentDefinitionException( + message: \sprintf( + "Invalid component definition: %s", + $exception->getMessage(), + ), + previous: $exception, + ); + } + } + + /** + * @return array + */ + private function createFieldDefinitions ( + \ReflectionClass $class, + bool $allowEmbeds = true, + ) : array + { + $definitions = []; + + foreach ($class->getProperties() as $reflectionProperty) + { + if ($reflectionProperty->isStatic()) + { + continue; + } + + // check for embeds + $embed = $this->helper->getOptionalSingleAttribute($reflectionProperty, EmbeddedStory::class); + + if (null !== $embed) + { + if (!$allowEmbeds) + { + throw new InvalidComponentDefinitionException(\sprintf( + "Can't use embedded field in embedded type at '%s'", + $this->formatPropertyName($reflectionProperty), + )); + } + + $embedClass = $this->getSingleClassType($reflectionProperty); + $definitions[$embed->prefix] = new EmbeddedFieldDefinition( + definition: $embed, + property: $reflectionProperty->getName(), + embedClass: $embedClass->getName(), + fields: $this->createFieldDefinitions($embedClass, allowEmbeds: false), + ); + + continue; + } + + $fieldDefinition = $this->helper->getOptionalSingleAttribute($reflectionProperty, AbstractField::class); + + if (null === $fieldDefinition) + { + continue; + } + + if ($this->isReservedFieldName($fieldDefinition->key)) + { + throw new InvalidComponentDefinitionException(\sprintf( + "Can't use reserved field name '%s' at '%s'", + $fieldDefinition->key, + $this->formatPropertyName($reflectionProperty), + )); + } + + $definitions[$fieldDefinition->key] = new FieldDefinition( + $fieldDefinition, + $reflectionProperty->getName(), + $this->helper->getAttributes($reflectionProperty, FieldAttributeInterface::class), + ); + } + + return $definitions; + } + + /** + */ + private function getSingleClassType (\ReflectionProperty $property) : \ReflectionClass + { + $type = $property->getType(); + + if (!$type instanceof \ReflectionNamedType) + { + throw new InvalidComponentDefinitionException( + message: \sprintf( + "Can't use non-singular object type on embedded field: %s", + $this->formatPropertyName($property), + ), + ); + } + + try + { + return new \ReflectionClass($type->getName()); + } + catch (\ReflectionException $exception) + { + throw new InvalidComponentDefinitionException( + message: \sprintf( + "Invalid type for embedded field '%s': %s", + $this->formatPropertyName($property), + $exception->getMessage(), + ), + previous: $exception, + ); + } + } + + + /** + * Returns whether the field uses a reserved field name + */ + private function isReservedFieldName (string $key) : bool + { + return "component" === $key; + } + + + /** + * + */ + private function formatPropertyName (\ReflectionProperty $property) : string + { + return \sprintf( + "%s::$%s", + $property->getDeclaringClass()->getName(), + $property->getName(), + ); + } +} diff --git a/src/Definition/Component/ComponentDefinitionRegistry.php b/src/Definition/Component/ComponentDefinitionRegistry.php new file mode 100644 index 0000000..5c7d937 --- /dev/null +++ b/src/Definition/Component/ComponentDefinitionRegistry.php @@ -0,0 +1,91 @@ + + */ + private array $componentsByTags; + + /** + * @var array + */ + private array $definitions; + + /** + * @param array $definitions + */ + public function __construct (array $definitions) + { + // sort components by name + \uasort( + $definitions, + static fn (ComponentDefinition $left, ComponentDefinition $right) => \strnatcasecmp($left->getName(), $right->getName()), + ); + + $this->definitions = $definitions; + $this->componentsByTags = $this->indexTags($definitions); + } + + + /** + * @param ComponentDefinition[] $definitions + */ + private function indexTags (array $definitions) : array + { + $index = []; + + foreach ($definitions as $definition) + { + foreach ($definition->definition->tags as $tag) + { + $tag = $tag instanceof \BackedEnum + ? $tag->value + : $tag; + $index[$tag][] = $definition; + } + } + + return $index; + } + + + public function get (string $key) : ComponentDefinition + { + $definition = $this->definitions[$key] ?? null; + + if (null === $definition) + { + throw new UnknownComponentKeyException( + message: \sprintf( + "Could not find component with key '%s'", + $key + ), + componentKey: $key, + ); + } + + return $definition; + } + + + /** + * @return ComponentDefinition[] + */ + public function getComponentsByTag (string $tag) : array + { + return $this->componentsByTags[$tag] ?? []; + } + + /** + * @return ComponentDefinition[] + */ + public function getComponents () : array + { + return $this->definitions; + } +} diff --git a/src/Definition/Field/EmbeddedFieldDefinition.php b/src/Definition/Field/EmbeddedFieldDefinition.php new file mode 100644 index 0000000..9fdbd23 --- /dev/null +++ b/src/Definition/Field/EmbeddedFieldDefinition.php @@ -0,0 +1,60 @@ + + */ + public array $fields; + + /** + * @param array $fields + */ + public function __construct ( + public EmbeddedStory $definition, + public string $property, + /** @var class-string */ + public string $embedClass, + array $fields, + ) + { + $transformed = []; + + foreach ($fields as $key => $field) + { + $transformed[$this->definition->prefix . $key] = $field; + } + + $this->fields = $transformed; + } + + + /** + * + */ + public function generateManagementApiDataForAllFields () : array + { + $schemas = []; + $prefix = \rtrim($this->definition->prefix, "_") . "_"; + + foreach ($this->fields as $key => $field) + { + $schemas[$key] = $field->generateManagementApiData(); + } + + + $schemas[$prefix . "_embed"] = [ + "type" => FieldType::Section->value, + "display_name" => $this->definition->label, + "keys" => \array_keys($this->fields), + ]; + + return $schemas; + } +} diff --git a/src/Definition/Field/FieldDefinition.php b/src/Definition/Field/FieldDefinition.php new file mode 100644 index 0000000..772feb3 --- /dev/null +++ b/src/Definition/Field/FieldDefinition.php @@ -0,0 +1,83 @@ +field->generateManagementApiData(); + + foreach ($this->attributes as $attribute) + { + foreach ($attribute->managementApiData as $key => $value) + { + $data[$key] = $value; + } + } + + return $data; + } + + + /** + * Validates the given data for the field + * + * @throws InvalidDataException + */ + public function validateData ( + array $contentPath, + DataValidator $validator, + mixed $data, + ) : void + { + $contentPath[] = \sprintf( + $this->field->key !== $this->property + ? "%s (%s)" + : "%s", + $this->property, + $this->field->key, + ); + $constraints = []; + + foreach ($this->attributes as $attribute) + { + foreach ($attribute->getValidationConstraints() as $constraint) + { + $constraints[] = $constraint; + } + } + + // validate attributes first + $validator->ensureDataIsValid( + $contentPath, + $data, + $constraints, + ); + + $this->field->validateData( + $contentPath, + $validator, + $data, + ); + } +} diff --git a/src/Definition/Reflection/ReflectionHelper.php b/src/Definition/Reflection/ReflectionHelper.php new file mode 100644 index 0000000..1e25bce --- /dev/null +++ b/src/Definition/Reflection/ReflectionHelper.php @@ -0,0 +1,93 @@ + $attributeClass + * @return AttributeType + * + * @throws InvalidComponentDefinitionException + */ + public function getRequiredSingleAttribute ( + \ReflectionClass|\ReflectionProperty $element, + string $attributeClass, + ) : object + { + $attribute = $this->getOptionalSingleAttribute($element, $attributeClass); + + if (null === $attribute) + { + throw new InvalidComponentDefinitionException(\sprintf( + "Could not find required attribute of type '%s' on class '%s'.", + $attributeClass, + $element->getName(), + )); + } + + return $attribute; + } + + + /** + * Instantiates and returns a single attribute + * + * @template AttributeType of object + * + * @param class-string $attributeClass + * @return AttributeType|null + * + * @throws InvalidComponentDefinitionException + */ + public function getOptionalSingleAttribute ( + \ReflectionClass|\ReflectionProperty $element, + string $attributeClass, + ) : ?object + { + $attributes = $this->getAttributes($element, $attributeClass); + + if (\count($attributes) > 1) + { + throw new InvalidComponentDefinitionException(\sprintf( + "Found multiple instances of attribute '%s', but expected only one.", + $attributeClass, + )); + } + + return $attributes[0] ?? null; + } + + + /** + * @template AttributeType of object + * + * @param class-string $attributeClass + * @return array + */ + public function getAttributes ( + \ReflectionClass|\ReflectionProperty $element, + string $attributeClass, + ) : array + { + $result = []; + + foreach ($element->getAttributes() as $reflectionAttribute) + { + if (\is_a($reflectionAttribute->getName(), $attributeClass, true)) + { + /** @var AttributeType $attribute */ + $attribute = $reflectionAttribute->newInstance(); + $result[] = $attribute; + } + } + + return $result; + } +} diff --git a/src/DependencyInjection/CollectComponentDefinitionsCompilerPass.php b/src/DependencyInjection/CollectComponentDefinitionsCompilerPass.php new file mode 100644 index 0000000..7ec61f6 --- /dev/null +++ b/src/DependencyInjection/CollectComponentDefinitionsCompilerPass.php @@ -0,0 +1,96 @@ +getDefinitions() as $id => $definition) + { + /** @var class-string|null $class */ + $class = $definition->getClass(); + + if (null === $class || !\str_contains($class, "Storyblok")) + { + continue; + } + + $attribute = $this->extractKey($class); + + if (null === $attribute) + { + continue; + } + + if (!\is_a($class, StandaloneNestedStory::class, true) && !\is_a($class, NestedStory::class, true)) + { + throw new InvalidComponentDefinitionException(\sprintf( + "Storyblok element '%s' must extend either '%s' or '%s'.", + $class, + StandaloneNestedStory::class, + NestedStory::class, + )); + } + + if (\array_key_exists($attribute->key, $definitions)) + { + throw new DuplicateComponentKeyException(\sprintf( + "Found multiple storyblok definitions for key '%s'. One in '%s' and one in '%s'", + $attribute->key, + $definitions[$attribute->key], + $definition->getClass(), + )); + } + + $definitions[$attribute->key] = $class; + $container->removeDefinition($id); + } + + $container->getDefinition(ComponentManager::class) + ->setArgument('$storyComponents', $definitions); + } + + + /** + * Extracts the key of the class + * + * @param class-string $class + */ + private function extractKey (string $class) : ?Storyblok + { + try + { + $reflectionClass = new \ReflectionClass($class); + $reflectionAttribute = $reflectionClass->getAttributes(Storyblok::class)[0] ?? null; + + if (null === $reflectionAttribute) + { + return null; + } + + $attribute = $reflectionAttribute->newInstance(); + \assert($attribute instanceof Storyblok); + + return $attribute; + } + catch (\ReflectionException) + { + return null; + } + } +} diff --git a/src/Exception/Component/DuplicateComponentKeyException.php b/src/Exception/Component/DuplicateComponentKeyException.php new file mode 100644 index 0000000..6b66904 --- /dev/null +++ b/src/Exception/Component/DuplicateComponentKeyException.php @@ -0,0 +1,9 @@ + $contentPath */ public readonly ?array $contentPath = null, - public readonly FieldDefinitionInterface|AbstractComponent|null $field = null, public readonly mixed $data = null, public readonly ?ConstraintViolationListInterface $violations = null, ?\Throwable $previous = null, diff --git a/src/Field/Collection/FieldCollection.php b/src/Field/Collection/FieldCollection.php index 7907367..c395117 100644 --- a/src/Field/Collection/FieldCollection.php +++ b/src/Field/Collection/FieldCollection.php @@ -3,17 +3,17 @@ namespace Torr\Storyblok\Field\Collection; use Torr\Storyblok\Exception\Story\UnknownFieldException; -use Torr\Storyblok\Field\FieldDefinitionInterface; +use Torr\Storyblok\Field\FieldDefinition; use Torr\Storyblok\Field\Helper\FieldDefinitionHelper; use Torr\Storyblok\Field\NestedFieldDefinitionInterface; final class FieldCollection { - /** @var array */ + /** @var array */ private array $allFields = []; public function __construct ( - /** @var array $rootFields */ + /** @var array $rootFields */ private readonly array $rootFields, ) { @@ -22,7 +22,7 @@ public function __construct ( } /** - * @param array $fields + * @param array $fields */ private function indexFields (array $fields) : void { @@ -39,7 +39,7 @@ private function indexFields (array $fields) : void /** - * @return array + * @return array */ public function getRootFields () : array { @@ -49,7 +49,7 @@ public function getRootFields () : array /** * Returns a single transformable field */ - public function getField (string $key) : FieldDefinitionInterface + public function getField (string $key) : FieldDefinition { $field = $this->allFields[$key] ?? null; diff --git a/src/Field/Definition/AbstractField.php b/src/Field/Definition/AbstractField.php index e2ddf86..1a148b2 100644 --- a/src/Field/Definition/AbstractField.php +++ b/src/Field/Definition/AbstractField.php @@ -2,7 +2,7 @@ namespace Torr\Storyblok\Field\Definition; -use Torr\Storyblok\Field\FieldDefinitionInterface; +use Torr\Storyblok\Field\FieldDefinition; use Torr\Storyblok\Field\FieldType; use Torr\Storyblok\Management\ManagementApiData; @@ -11,7 +11,7 @@ * * @internal */ -abstract class AbstractField implements FieldDefinitionInterface +abstract class AbstractField implements FieldDefinition { private ?bool $canSync = false; private ?bool $useAsAdminDisplayName = false; diff --git a/src/Field/Definition/AssetField.php b/src/Field/Definition/AssetField.php index 22affcc..eff3b05 100644 --- a/src/Field/Definition/AssetField.php +++ b/src/Field/Definition/AssetField.php @@ -10,6 +10,7 @@ use Torr\Storyblok\Field\Asset\AssetFileType; use Torr\Storyblok\Field\Data\AssetData; use Torr\Storyblok\Field\FieldType; +use Torr\Storyblok\Transformer\DataTransformer; use Torr\Storyblok\Visitor\DataVisitorInterface; /** @@ -157,12 +158,12 @@ public function transformData ( $transformed = new AssetData( url: $assetUrl, id: $data["id"], - alt: $context->normalizeOptionalString($data["alt"] ?? null), - name: $context->normalizeOptionalString($data["name"] ?? null), - focus: $context->normalizeOptionalString($data["focus"] ?? null), - title: $context->normalizeOptionalString($data["title"] ?? null), - source: $context->normalizeOptionalString($data["source"] ?? null), - copyright: $context->normalizeOptionalString($data["copyright"] ?? null), + alt: DataTransformer::normalizeOptionalString($data["alt"] ?? null), + name: DataTransformer::normalizeOptionalString($data["name"] ?? null), + focus: DataTransformer::normalizeOptionalString($data["focus"] ?? null), + title: DataTransformer::normalizeOptionalString($data["title"] ?? null), + source: DataTransformer::normalizeOptionalString($data["source"] ?? null), + copyright: DataTransformer::normalizeOptionalString($data["copyright"] ?? null), isExternal: $data["is_external_url"] ?? false, width: $width, height: $height, diff --git a/src/Field/Definition/ChoiceField.php b/src/Field/Definition/ChoiceField.php index ad11c07..ff80d24 100644 --- a/src/Field/Definition/ChoiceField.php +++ b/src/Field/Definition/ChoiceField.php @@ -12,6 +12,7 @@ use Torr\Storyblok\Exception\InvalidFieldConfigurationException; use Torr\Storyblok\Field\Choices\ChoicesInterface; use Torr\Storyblok\Field\FieldType; +use Torr\Storyblok\Transformer\DataTransformer; use Torr\Storyblok\Visitor\DataVisitorInterface; final class ChoiceField extends AbstractField @@ -125,7 +126,7 @@ private function validateSingleSelect (ComponentContext $context, array $content \assert(null === $data || \is_string($data) || \is_int($data)); - $data = $context->normalizeOptionalString((string) $data); + $data = DataTransformer::normalizeOptionalString((string) $data); $context->ensureDataIsValid( $contentPath, @@ -180,7 +181,7 @@ private function validateMultiSelect (ComponentContext $context, array $contentP \assert(null === $data || \is_array($data)); $data = \array_map( - static fn (mixed $value) => $context->normalizeOptionalString((string) $value), + static fn (mixed $value) => DataTransformer::normalizeOptionalString((string) $value), // we are in a multiselect, so we expect an array $data ?? [], ); @@ -220,7 +221,7 @@ public function transformData ( if (\is_string($data)) { - $data = $context->normalizeOptionalString($data); + $data = DataTransformer::normalizeOptionalString($data); } $transformed = null !== $data diff --git a/src/Field/Definition/DateTimeField.php b/src/Field/Definition/DateTimeField.php index 2d00528..4b54fbe 100644 --- a/src/Field/Definition/DateTimeField.php +++ b/src/Field/Definition/DateTimeField.php @@ -7,6 +7,7 @@ use Symfony\Component\Validator\Constraints\Type; use Torr\Storyblok\Context\ComponentContext; use Torr\Storyblok\Field\FieldType; +use Torr\Storyblok\Transformer\DataTransformer; use Torr\Storyblok\Visitor\DataVisitorInterface; final class DateTimeField extends AbstractField @@ -76,7 +77,7 @@ public function transformData ( \assert(null === $data || \is_string($data)); // empty fields are also passed as "" - $data = $context->normalizeOptionalString($data); + $data = DataTransformer::normalizeOptionalString($data); $transformed = null !== $data ? \DateTimeImmutable::createFromFormat(self::DATE_TIME_FORMAT, $data) diff --git a/src/Field/Definition/LinkField.php b/src/Field/Definition/LinkField.php index f0dd57d..f35028d 100644 --- a/src/Field/Definition/LinkField.php +++ b/src/Field/Definition/LinkField.php @@ -15,6 +15,7 @@ use Torr\Storyblok\Field\Data\StoryLinkData; use Torr\Storyblok\Field\FieldType; use Torr\Storyblok\Manager\Sync\Filter\ResolvableComponentFilter; +use Torr\Storyblok\Transformer\DataTransformer; use Torr\Storyblok\Visitor\DataVisitorInterface; final class LinkField extends AbstractField @@ -150,18 +151,18 @@ private function transformDataToLink ( { if ("story" === $data["linktype"]) { - $id = $context->normalizeOptionalString($data["id"]); + $id = DataTransformer::normalizeOptionalString($data["id"]); // we have the cached_url in the data here, but we can't rely on it, as it might be out of date return new StoryLinkData( id: $id, - anchor: $context->normalizeOptionalString($data["anchor"] ?? null), + anchor: DataTransformer::normalizeOptionalString($data["anchor"] ?? null), ); } if ("email" === $data["linktype"]) { - $email = $context->normalizeOptionalString($data["email"]); + $email = DataTransformer::normalizeOptionalString($data["email"]); return null !== $email ? new EmailLinkData($email) @@ -170,7 +171,7 @@ private function transformDataToLink ( if ("asset" === $data["linktype"]) { - $url = $context->normalizeOptionalString($data["url"]); + $url = DataTransformer::normalizeOptionalString($data["url"]); if (null === $url) { @@ -182,7 +183,7 @@ private function transformDataToLink ( } // "url" === $data["linktype"] - $url = $context->normalizeOptionalString($data["url"]); + $url = DataTransformer::normalizeOptionalString($data["url"]); return null !== $url ? new ExternalLinkData($url) diff --git a/src/Field/Definition/MarkdownField.php b/src/Field/Definition/MarkdownField.php index e32e25b..b0c99fd 100644 --- a/src/Field/Definition/MarkdownField.php +++ b/src/Field/Definition/MarkdownField.php @@ -6,6 +6,7 @@ use Symfony\Component\Validator\Constraints\Type; use Torr\Storyblok\Context\ComponentContext; use Torr\Storyblok\Field\FieldType; +use Torr\Storyblok\Transformer\DataTransformer; use Torr\Storyblok\Visitor\DataVisitorInterface; final class MarkdownField extends AbstractField @@ -75,7 +76,7 @@ public function transformData ( { \assert(null === $data || \is_string($data)); - $transformed = $context->dataTransformer->normalizeOptionalString($data); + $transformed = DataTransformer::normalizeOptionalString($data); $dataVisitor?->onDataVisit($this, $transformed); return $transformed; diff --git a/src/Field/Definition/NumberField.php b/src/Field/Definition/NumberField.php deleted file mode 100644 index 74736e9..0000000 --- a/src/Field/Definition/NumberField.php +++ /dev/null @@ -1,74 +0,0 @@ -ensureDataIsValid( - $contentPath, - $this, - $data, - [ - !$this->allowMissingData && $this->required ? new NotNull() : null, - // numbers are always passed as strings - new Type("string"), - new Regex("~^\\d+(\\.\\d+)?$~"), - ], - ); - } - - /** - * @inheritDoc - */ - public function transformData ( - mixed $data, - ComponentContext $context, - array $fullData, - ?DataVisitorInterface $dataVisitor = null, - ) : int|float|null - { - \assert(null === $data || \is_string($data)); - - if (null !== $data && "" !== $data) - { - $transformed = \str_contains($data, ".") - ? (float) $data - : (int) $data; - } - else - { - $transformed = null; - } - - $dataVisitor?->onDataVisit($this, $transformed); - return $transformed; - } -} diff --git a/src/Field/Definition/TextField.php b/src/Field/Definition/TextField.php deleted file mode 100644 index ed9bd39..0000000 --- a/src/Field/Definition/TextField.php +++ /dev/null @@ -1,85 +0,0 @@ -multiline - ? FieldType::TextArea - : FieldType::Text; - } - - /** - * @inheritDoc - */ - protected function toManagementApiData () : array - { - return \array_replace( - parent::toManagementApiData(), - [ - "rtl" => $this->isRightToLeft, - "max_length" => $this->maxLength, - ], - ); - } - - /** - * @inheritDoc - */ - public function validateData (ComponentContext $context, array $contentPath, mixed $data, array $fullData) : void - { - $context->ensureDataIsValid( - $contentPath, - $this, - $data, - [ - !$this->allowMissingData && $this->required ? new NotNull() : null, - new Type("string"), - // We can't validate the length here, as it is not guaranteed if you add - // the max-length after content was added. - ], - ); - } - - /** - * @inheritDoc - */ - public function transformData ( - mixed $data, - ComponentContext $context, - array $fullData, - ?DataVisitorInterface $dataVisitor = null, - ) : ?string - { - \assert(null === $data || \is_string($data)); - $transformed = $context->normalizeOptionalString($data); - - $dataVisitor?->onDataVisit($this, $transformed); - return $transformed; - } -} diff --git a/src/Field/FieldDefinition.php b/src/Field/FieldDefinition.php new file mode 100644 index 0000000..c363f64 --- /dev/null +++ b/src/Field/FieldDefinition.php @@ -0,0 +1,8 @@ + $fields + * @param array $fields */ public function __construct ( string $label, - /** @var array $fields */ + /** @var array $fields */ protected readonly array $fields, ) { diff --git a/src/Field/Group/CompositeField.php b/src/Field/Group/CompositeField.php index f86dbcd..ef35e92 100644 --- a/src/Field/Group/CompositeField.php +++ b/src/Field/Group/CompositeField.php @@ -4,7 +4,7 @@ use Symfony\Component\String\Slugger\AsciiSlugger; use Torr\Storyblok\Context\ComponentContext; -use Torr\Storyblok\Field\FieldDefinitionInterface; +use Torr\Storyblok\Field\FieldDefinition; use Torr\Storyblok\Field\FieldType; use Torr\Storyblok\Visitor\DataVisitorInterface; @@ -41,7 +41,7 @@ public function __construct ( /** * Configures the fields * - * @return array + * @return array */ abstract protected function configureFields () : array; diff --git a/src/Field/Helper/FieldDefinitionHelper.php b/src/Field/Helper/FieldDefinitionHelper.php index f2226c7..0a4f4c0 100644 --- a/src/Field/Helper/FieldDefinitionHelper.php +++ b/src/Field/Helper/FieldDefinitionHelper.php @@ -4,14 +4,14 @@ use Torr\Storyblok\Exception\InvalidComponentConfigurationException; use Torr\Storyblok\Field\Definition\AbstractField; -use Torr\Storyblok\Field\FieldDefinitionInterface; +use Torr\Storyblok\Field\FieldDefinition; final class FieldDefinitionHelper { /** * Ensures that there is a only a single field with an admin display preview * - * @param iterable $fields + * @param iterable $fields */ public static function ensureMaximumOneAdminDisplayName (iterable $fields) : void { diff --git a/src/Field/NestedFieldDefinitionInterface.php b/src/Field/NestedFieldDefinitionInterface.php index a9cb4b1..4b8345f 100644 --- a/src/Field/NestedFieldDefinitionInterface.php +++ b/src/Field/NestedFieldDefinitionInterface.php @@ -7,7 +7,7 @@ interface NestedFieldDefinitionInterface /** * Returns the nested fields * - * @return FieldDefinitionInterface[] + * @return FieldDefinition[] */ public function getNestedFields () : array; } diff --git a/src/Hydrator/StoryHydrator.php b/src/Hydrator/StoryHydrator.php new file mode 100644 index 0000000..33dcfa1 --- /dev/null +++ b/src/Hydrator/StoryHydrator.php @@ -0,0 +1,202 @@ +isUnsavedStory($data["content"])) + { + $this->logger->warning("Skipping unsaved story {id} of type {type}", [ + "id" => $data["id"] ?? "n/a", + "type" => $type, + ]); + + return null; + } + + try + { + return $this->hydrateDocument($type, $data); + } + catch (InvalidDataException $exception) + { + throw new StoryHydrationFailed(\sprintf( + "Failed to hydrate story (Id: '%s', Name: '%s') of type '%s' due to invalid data: %s", + $data["id"] ?? "n/a", + $data["name"] ?? "n/a", + $type, + $exception->getMessage(), + ), previous: $exception); + } + catch (UnknownComponentKeyException $exception) + { + $this->logger->warning("Could not hydrate story {id} of type {type}: {message}", [ + "id" => $data["id"] ?? "n/a", + "type" => $type, + "message" => $exception->getMessage(), + "exception" => $exception, + ]); + + return null; + } + } + + /** + * Hydrates the document with the data + */ + public function hydrateDocument ( + string $type, + array $data, + ) : StandaloneNestedStory + { + $definition = $this->componentManager->getDefinition($type); + $storyClass = $definition->storyClass; + + \assert(\is_a($storyClass, StandaloneNestedStory::class, true)); + + $metaData = new StandaloneStoryMetaData($data, $type); + $document = new $storyClass($metaData); + + return $this->mapDataToFields( + [\sprintf("%s (%s)", $storyClass, $type)], + $document, + $definition->fields, + $data["content"], + ); + } + + /** + * + */ + public function hydrateBlok ( + array $contentPath, + string $type, + array $data, + ) : NestedStory + { + $definition = $this->componentManager->getDefinition($type); + $blokClass = $definition->storyClass; + + \assert(\is_a($blokClass, NestedStory::class, true)); + + $metaData = new NestedStoryMetaData( + $data["_uid"], + $data["component"], + $data["_editable"], + ); + $blok = new $blokClass($metaData); + + return $this->mapDataToFields( + [...$contentPath, \sprintf("%s (%s)", $blokClass, $type)], + $blok, + $definition->fields, + $data, + ); + } + + + /** + * @template ContentElement of object + * + * @param ContentElement $item + * @param array $fields + * @return ContentElement + */ + private function mapDataToFields ( + array $contentPath, + object $item, + array $fields, + array $completeData, + ) : object + { + foreach ($fields as $fieldKey => $field) + { + if ($field instanceof EmbeddedFieldDefinition) + { + $embed = new ($field->embedClass)(); + $transformed = $this->mapDataToFields( + [...$contentPath, $field->property], + $embed, + $field->fields, + $completeData, + ); + } + else + { + $data = $completeData[$fieldKey] ?? null; + + // validate data + $field->validateData( + $contentPath, + $this->dataValidator, + $data, + ); + + $transformed = $field->field->transformRawData($contentPath, $data, $this); + } + + // map data + $this->accessor->setValue( + $item, + $field->property, + $transformed, + ); + } + + return $item; + } + + + /** + * Checks the content of the story to see, whether the story was saved at least once + */ + private function isUnsavedStory (array $content) : bool + { + // a story was not yet saved, if we have no additional data except for the uid, component type key and the editable HTML snippet + return 3 === \count($content) && isset($content["_uid"], $content["component"], $content["_editable"]); + } +} diff --git a/src/Management/ManagementApiData.php b/src/Management/ManagementApiData.php deleted file mode 100644 index 144e7b3..0000000 --- a/src/Management/ManagementApiData.php +++ /dev/null @@ -1,35 +0,0 @@ -fields)) - { - throw new InvalidComponentConfigurationException(\sprintf( - "Invalid component configuration: field key '%s' used more than once", - $key, - )); - } - - $config["pos"] = \count($this->fields); - $this->fields[$key] = $config; - } - - /** - * Returns the full config - */ - public function getFullConfig () : array - { - return $this->fields; - } -} diff --git a/src/Manager/ComponentManager.php b/src/Manager/ComponentManager.php index eb7f374..1e88c77 100644 --- a/src/Manager/ComponentManager.php +++ b/src/Manager/ComponentManager.php @@ -3,22 +3,46 @@ namespace Torr\Storyblok\Manager; use Symfony\Component\DependencyInjection\Exception\ServiceNotFoundException; -use Symfony\Component\DependencyInjection\ServiceLocator; +use Symfony\Contracts\Cache\CacheInterface; +use Torr\Storyblok\Cache\DebugCache; +use Torr\Storyblok\Cache\DebugCacheFactory; use Torr\Storyblok\Component\AbstractComponent; +use Torr\Storyblok\Definition\Component\ComponentDefinition; +use Torr\Storyblok\Definition\Component\ComponentDefinitionFactory; +use Torr\Storyblok\Definition\Component\ComponentDefinitionRegistry; +use Torr\Storyblok\Exception\Component\InvalidComponentDefinitionException; use Torr\Storyblok\Exception\Component\UnknownComponentKeyException; use Torr\Storyblok\Exception\Component\UnknownStoryTypeException; -use Torr\Storyblok\Story\Story; +use Torr\Storyblok\Mapping\Storyblok; +use Torr\Storyblok\Story\StandaloneNestedStory; /** * @final */ class ComponentManager { + private const CACHE_KEY = "storyblok.definitions"; + + /** @var DebugCache */ + private DebugCache $definitionCache; + /** */ public function __construct ( - private readonly ServiceLocator $components, - ) {} + /** @var array */ + array $storyComponents, + ComponentDefinitionFactory $definitionFactory, + DebugCacheFactory $debugCacheFactory, + ) + { + $this->definitionCache = $debugCacheFactory->createCache( + self::CACHE_KEY, + fn () => $definitionFactory->generateAllDefinitions($storyComponents), + ); + } + + + /** * @return array @@ -41,13 +65,13 @@ public function getAllComponents () : array /** * Returns the first component that creates a story of the given type * - * @template TStory of Story + * @template TStory of StandaloneNestedStory * * @param class-string $storyType * - * @throws UnknownStoryTypeException - * * @return AbstractComponent + *@throws UnknownStoryTypeException + * */ public function getComponentByStoryType (string $storyType) : AbstractComponent { @@ -123,4 +147,19 @@ public function getComponent (string $key) : AbstractComponent ); } } + + /** + */ + public function getDefinitions () : ComponentDefinitionRegistry + { + return $this->definitionCache->get(); + } + + /** + * Returns the definition of the component + */ + public function getDefinition (string $key) : ComponentDefinition + { + return $this->getDefinitions()->get($key); + } } diff --git a/src/Manager/Normalizer/ComponentNormalizer.php b/src/Manager/Normalizer/ComponentNormalizer.php deleted file mode 100644 index 28e593d..0000000 --- a/src/Manager/Normalizer/ComponentNormalizer.php +++ /dev/null @@ -1,48 +0,0 @@ -componentManager->getAllComponents() as $component) - { - $key = \sprintf( - "%s (%s) ... ", - $component->getDisplayName(), - $component::getKey(), - ); - - $io->write("Normalizing {$key} "); - - $normalized[$key] = new ComponentImport( - $this->componentConfigResolver->resolveComponentConfig($component->toManagementApiData()), - $component->getComponentGroup(), - ); - - $io->writeln("done ✓"); - } - - return $normalized; - } -} diff --git a/src/Manager/Sync/ComponentSync.php b/src/Manager/Sync/ComponentSync.php index 67c2341..7e6276c 100644 --- a/src/Manager/Sync/ComponentSync.php +++ b/src/Manager/Sync/ComponentSync.php @@ -5,10 +5,11 @@ use Torr\Cli\Console\Style\TorrStyle; use Torr\Storyblok\Api\Data\ComponentImport; use Torr\Storyblok\Api\ManagementApi; +use Torr\Storyblok\Definition\Component\ComponentDefinition; use Torr\Storyblok\Exception\Api\ApiRequestException; use Torr\Storyblok\Exception\InvalidComponentConfigurationException; use Torr\Storyblok\Exception\Sync\SyncFailedException; -use Torr\Storyblok\Manager\Normalizer\ComponentNormalizer; +use Torr\Storyblok\Manager\ComponentManager; final class ComponentSync { @@ -16,7 +17,7 @@ final class ComponentSync */ public function __construct ( private readonly ManagementApi $managementApi, - private readonly ComponentNormalizer $componentNormalizer, + private readonly ComponentManager $componentManager, ) {} /** @@ -31,7 +32,19 @@ public function syncDefinitions ( { try { - $normalized = $this->componentNormalizer->normalize($io); + $normalized = []; + $registry = $this->componentManager->getDefinitions(); + + $io->listing( + \array_map( + static fn (ComponentDefinition $definition) => \sprintf( + "%s (%s) ... ", + $definition->getName(), + $definition->getKey(), + ), + $registry->getComponents(), + ), + ); if (!$sync) { diff --git a/src/Manager/Validator/ComponentValidator.php b/src/Manager/Validator/ComponentValidator.php deleted file mode 100644 index 8cf4163..0000000 --- a/src/Manager/Validator/ComponentValidator.php +++ /dev/null @@ -1,31 +0,0 @@ -componentNormalizer->normalize($io); - } - catch (InvalidComponentConfigurationException|ApiRequestException $exception) - { - throw new ValidationFailedException($exception->getMessage(), previous: $exception); - } - } -} diff --git a/src/Mapping/Embed/EmbeddedStory.php b/src/Mapping/Embed/EmbeddedStory.php new file mode 100644 index 0000000..3e262f7 --- /dev/null +++ b/src/Mapping/Embed/EmbeddedStory.php @@ -0,0 +1,19 @@ +prefix = \rtrim($prefix, "_") . "_"; + } +} diff --git a/src/Mapping/Field/AbstractField.php b/src/Mapping/Field/AbstractField.php new file mode 100644 index 0000000..59804f0 --- /dev/null +++ b/src/Mapping/Field/AbstractField.php @@ -0,0 +1,64 @@ + $this->internalStoryblokType->value, + "display_name" => $this->getLabel(), + "default_value" => $this->defaultValue, + ]; + } + + /** + * + */ + public function getLabel () : string + { + return $this->label ?? u($this->key) + ->title() + ->toString(); + } + + /** + * Transforms the raw data from Storyblok to a sanitized format. + */ + public function transformRawData ( + array $contentPath, + mixed $data, + StoryHydrator $hydrator, + ) : mixed + { + return $data; + } + + + /** + * Validates the given data + */ + public function validateData ( + array $contentPath, + DataValidator $validator, + mixed $data, + ) : void + {} +} diff --git a/src/Mapping/Field/BloksField.php b/src/Mapping/Field/BloksField.php new file mode 100644 index 0000000..485cc84 --- /dev/null +++ b/src/Mapping/Field/BloksField.php @@ -0,0 +1,123 @@ +minimumNumberOfBloks && $this->minimumNumberOfBloks < 0) + { + throw new InvalidFieldConfigurationException(\sprintf( + "The minimum number of blocks (%d) can't be negative", + $this->minimumNumberOfBloks, + )); + } + + if (null !== $this->maximumNumberOfBloks && $this->maximumNumberOfBloks < 0) + { + throw new InvalidFieldConfigurationException(\sprintf( + "The maximum number of blocks (%d) can't be negative", + $this->maximumNumberOfBloks, + )); + } + + if ( + null !== $this->minimumNumberOfBloks + && null !== $this->maximumNumberOfBloks + && $this->minimumNumberOfBloks > $this->maximumNumberOfBloks + ) + { + throw new InvalidFieldConfigurationException(\sprintf( + "The minimum number of blocks (%d) value can't be higher than the maximum (%d)", + $this->minimumNumberOfBloks, + $this->maximumNumberOfBloks, + )); + } + } + + /** + * @inheritDoc + */ + public function validateData (array $contentPath, DataValidator $validator, mixed $data) : void + { + $data ??= []; + + $validator->ensureDataIsValid( + $contentPath, + $data, + [ + new Type("array"), + null !== $this->minimumNumberOfBloks || null !== $this->maximumNumberOfBloks + ? new Count( + min: $this->minimumNumberOfBloks, + max: $this->maximumNumberOfBloks, + ) + : null, + new All( + constraints: [ + new NotNull(), + new Type("array"), + new Collection( + fields: [ + "component" => [ + new NotNull(), + new Type("string"), + ], + ], + allowExtraFields: true, + allowMissingFields: false, + ), + ], + ), + ], + ); + } + + /** + * @inheritDoc + */ + public function transformRawData (array $contentPath, mixed $data, StoryHydrator $hydrator) : array + { + $data ??= []; + \assert(\is_array($data)); + + $result = []; + + foreach ($data as $index => $entry) + { + $type = $entry["component"]; + $result[] = $hydrator->hydrateBlok( + [...$contentPath, \sprintf("Index #%d", $index)], + $type, + $entry, + ); + } + + return $result; + } +} diff --git a/src/Mapping/Field/NumberField.php b/src/Mapping/Field/NumberField.php new file mode 100644 index 0000000..638d9b0 --- /dev/null +++ b/src/Mapping/Field/NumberField.php @@ -0,0 +1,116 @@ +numberOfDecimals < 0) + { + throw new InvalidFieldConfigurationException(\sprintf( + "numberOfDecimals must be a positive integer, but is %d", + $this->numberOfDecimals, + )); + } + + parent::__construct( + FieldType::Number, + $key, + $label, + $defaultValue, + ); + } + + /** + * @inheritDoc + */ + public function generateManagementApiData () : array + { + return \array_replace(parent::generateManagementApiData(), [ + "min_value" => $this->minValue, + "max_value" => $this->maxValue, + "decimals" => $this->numberOfDecimals, + "steps" => $this->numberOfSteps, + ]); + } + + /** + * @inheritDoc + */ + public function transformRawData (array $contentPath, mixed $data, StoryHydrator $hydrator) : int|float|null + { + if (null === $data) + { + return null; + } + + \assert(\is_string($data)); + + return 0 === $this->numberOfDecimals + ? (int) $data + : (float) $data; + } + + + /** + * @inheritDoc + */ + public function validateData (array $contentPath, DataValidator $validator, mixed $data,) : void + { + $mustBeInt = 0 === $this->numberOfDecimals; + + $constraints = [ + new Type("string"), + new Regex( + $mustBeInt + ? "~^\d+$~" + : "~^\d+(\\.\d+)?$~", + message: $mustBeInt + ? "storyblok.field.number.must-be-integer" + : "storyblok.field.number.must-be-float", + ) + ]; + + if (null !== $this->minValue || null !== $this->maxValue) + { + $constraints[] = new Range( + min: $this->minValue, + max: $this->maxValue, + ); + } + + $validator->ensureDataIsValid($contentPath, $data, $constraints); + } + + +} diff --git a/src/Mapping/Field/TextField.php b/src/Mapping/Field/TextField.php new file mode 100644 index 0000000..f620cbf --- /dev/null +++ b/src/Mapping/Field/TextField.php @@ -0,0 +1,70 @@ +multiline + ? FieldType::TextArea + : FieldType::Text, + key: $key, + label: $label, + defaultValue: $defaultValue, + ); + } + + /** + * @inheritDoc + */ + public function transformRawData (array $contentPath, mixed $data, StoryHydrator $hydrator) : ?string + { + \assert(null === $data || \is_string($data)); + + return DataTransformer::normalizeOptionalString($data); + } + + + /** + * @inheritDoc + */ + public function validateData (array $contentPath, DataValidator $validator, mixed $data) : void + { + $validator->ensureDataIsValid($contentPath, $data, [ + new Type("string"), + ]); + } + + + /** + * @inheritDoc + */ + public function generateManagementApiData () : array + { + return \array_replace( + parent::generateManagementApiData(), + [ + "rtl" => $this->isRightToLeft, + "max_length" => $this->maxLength, + "no_translate" => $this->excludeFromTranslationExport, + ], + ); + } +} diff --git a/src/Mapping/FieldAttribute/FieldAttributeInterface.php b/src/Mapping/FieldAttribute/FieldAttributeInterface.php new file mode 100644 index 0000000..5d088d4 --- /dev/null +++ b/src/Mapping/FieldAttribute/FieldAttributeInterface.php @@ -0,0 +1,24 @@ + $this->description, + "tooltip" => $this->showAsTooltip, + ]); + } +} diff --git a/src/Mapping/FieldAttribute/WithValidation.php b/src/Mapping/FieldAttribute/WithValidation.php new file mode 100644 index 0000000..c71de86 --- /dev/null +++ b/src/Mapping/FieldAttribute/WithValidation.php @@ -0,0 +1,48 @@ + $this->required, + "regex" => $this->regexp, + ]); + } + + + /** + * @inheritDoc + */ + public function getValidationConstraints () : array + { + $constraints = []; + + if ($this->required && !$this->allowMissingData) + { + // we need to use NotBlank instead of NotNull here, as Storyblok often + // also returns empty strings for empty fields + $constraints[] = new NotBlank(); + } + + if (null !== $this->regexp) + { + $constraints[] = new Regex("~" . $this->regexp . "~"); + } + + return $constraints; + } +} diff --git a/src/Mapping/Storyblok.php b/src/Mapping/Storyblok.php new file mode 100644 index 0000000..98dde46 --- /dev/null +++ b/src/Mapping/Storyblok.php @@ -0,0 +1,32 @@ + */ + public array $tags = [], + public ?string $previewField = null, + public string|\BackedEnum|null $group = null, + ) {} + + + /** + * + */ + public function getName () : string + { + return $this->name ?? u($this->key) + ->title() + ->toString(); + } +} diff --git a/src/Story/Helper/ComponentPreviewData.php b/src/Story/Helper/ComponentPreviewData.php index 4dcf1bb..0047674 100644 --- a/src/Story/Helper/ComponentPreviewData.php +++ b/src/Story/Helper/ComponentPreviewData.php @@ -2,26 +2,16 @@ namespace Torr\Storyblok\Story\Helper; -use Torr\Storyblok\Component\Data\ComponentData; use Torr\Storyblok\Exception\Story\InvalidDataException; -use Torr\Storyblok\Story\StoryMetaData; +use Torr\Storyblok\Story\MetaData\NestedStoryMetaData; final class ComponentPreviewData { /** * Normalizes the preview-related data to return it directly */ - public static function normalizeData (StoryMetaData|ComponentData $metaData) : array + public static function normalizeData (NestedStoryMetaData $metaData) : array { - if ($metaData instanceof ComponentData) - { - return [ - "_uid" => $metaData->uid, - "_type" => $metaData->type, - "_preview" => self::parsePreviewTag($metaData->previewData), - ]; - } - return [ "_uid" => $metaData->getUuid(), "_type" => $metaData->getType(), diff --git a/src/Story/MetaData/NestedStoryMetaData.php b/src/Story/MetaData/NestedStoryMetaData.php new file mode 100644 index 0000000..472986a --- /dev/null +++ b/src/Story/MetaData/NestedStoryMetaData.php @@ -0,0 +1,38 @@ +uuid; + } + + /** + * + */ + public function getType () : string + { + return $this->type; + } + + /** + * + */ + public function getPreviewData () : ?string + { + return $this->previewData; + } +} diff --git a/src/Story/StoryMetaData.php b/src/Story/MetaData/StandaloneStoryMetaData.php similarity index 88% rename from src/Story/StoryMetaData.php rename to src/Story/MetaData/StandaloneStoryMetaData.php index 9452cf5..6571a8d 100644 --- a/src/Story/StoryMetaData.php +++ b/src/Story/MetaData/StandaloneStoryMetaData.php @@ -1,27 +1,26 @@ previewData = $data["content"]["_editable"] ?? null; + parent::__construct( + $data["uuid"], + $data["content"]["component"], + $data["content"]["_editable"] ?? null, + ); unset($data["content"]); $this->data = $data; $this->slugSegments = \explode("/", \rtrim($data["full_slug"], "/")); @@ -61,14 +60,6 @@ public function getId () : int return $this->data["id"]; } - /** - * - */ - public function getUuid () : string - { - return $this->data["uuid"]; - } - /** * */ @@ -101,13 +92,6 @@ public function getLocale () : string return $this->data["lang"]; } - /** - */ - public function getType () : string - { - return $this->type; - } - /** * Returns the slug of the parent * (without trailing slash). @@ -209,9 +193,4 @@ public function getTranslatedDocumentsMapping () : array return $mapping; } - - public function getPreviewData () : ?string - { - return $this->previewData; - } } diff --git a/src/Story/NestedStory.php b/src/Story/NestedStory.php new file mode 100644 index 0000000..f01767a --- /dev/null +++ b/src/Story/NestedStory.php @@ -0,0 +1,35 @@ +metaData->getUuid(); + } + + + /** + */ + public function getMetaData () : NestedStoryMetaData + { + return $this->metaData; + } +} + diff --git a/src/Story/StandaloneNestedStory.php b/src/Story/StandaloneNestedStory.php new file mode 100644 index 0000000..f9eeb9a --- /dev/null +++ b/src/Story/StandaloneNestedStory.php @@ -0,0 +1,36 @@ +metaData instanceof StandaloneStoryMetaData); + return $this->metaData; + } + + /** + */ + final public function getFullSlug () : string + { + return $this->getMetaData()->getFullSlug(); + } +} diff --git a/src/Story/Story.php b/src/Story/Story.php deleted file mode 100644 index 7ba9c1f..0000000 --- a/src/Story/Story.php +++ /dev/null @@ -1,90 +0,0 @@ -content = $data["content"]; - $this->metaData = new StoryMetaData($data, $this->rootComponent::getKey()); - } - - /** - * @inheritDoc - */ - final public function getUuid () : string - { - return $this->metaData->getUuid(); - } - - /** - * @inheritDoc - */ - final public function getMetaData () : StoryMetaData - { - return $this->metaData; - } - - /** - * @inheritDoc - */ - final public function getFullSlug () : string - { - return $this->metaData->getFullSlug(); - } - - /** - * - */ - public function validate (ComponentContext $context) : void - { - $this->rootComponent->validateData( - $context, - $this->content, - label: $this->metaData->getFullSlug(), - ); - } - - - /** - * Returns the transformed data for a single field - */ - protected function getTransformedFieldData ( - string $fieldName, - ?DataVisitorInterface $dataVisitor = null, - ) : mixed - { - return $this->rootComponent->transformField( - $this->content, - $fieldName, - $this->dataContext, - $dataVisitor, - ); - } - - /** - * - */ - public function __debugInfo () : ?array - { - return [ - // hide implementations of these, as they have huge dependency trees - "\0*\0rootComponent" => \get_class($this->rootComponent), - "\0*\0dataContext" => "...", - ]; - } -} diff --git a/src/Story/StoryFactory.php b/src/Story/StoryFactory.php deleted file mode 100644 index 6ba4ac9..0000000 --- a/src/Story/StoryFactory.php +++ /dev/null @@ -1,110 +0,0 @@ -isUnsavedStory($data["content"])) - { - $this->logger->warning("Skipping unsaved story {id} of type {type}", [ - "id" => $data["id"] ?? "n/a", - "type" => $type, - ]); - - return null; - } - - try - { - $component = $this->componentManager->getComponent($type); - $storyClass = $component->getStoryClass(); - - if (null === $storyClass) - { - throw new ComponentWithoutStoryException(\sprintf( - "Can't create story for component of type '%s', as no story class was defined.", - $component::getKey(), - )); - } - - if (!\is_a($storyClass, Story::class, true)) - { - throw new StoryHydrationFailed(\sprintf( - "Could not hydrate story of type '%s': story class does not extend %s", - $component::getKey(), - Story::class, - )); - } - - $story = new $storyClass($data, $component, $this->storyblokContext); - $story->validate($this->storyblokContext); - - return $story; - } - catch (InvalidDataException $exception) - { - throw new StoryHydrationFailed(\sprintf( - "Failed to hydrate story (Id: '%s', Name: '%s') of type '%s' due to invalid data: %s", - $data["id"] ?? "n/a", - $data["name"] ?? "n/a", - $type, - $exception->getMessage(), - ), previous: $exception); - } - catch (UnknownComponentKeyException $exception) - { - $this->logger->warning("Could not hydrate story {id} of type {type}: {message}", [ - "id" => $data["id"] ?? "n/a", - "type" => $type, - "message" => $exception->getMessage(), - "exception" => $exception, - ]); - - return null; - } - } - - /** - * Checks the content of the story to see, whether the story was saved at least once - */ - private function isUnsavedStory (array $content) : bool - { - // a story was not yet saved, if we have no additional data except for the uid, component type key and the editable HTML snippet - return 3 === \count($content) && isset($content["_uid"], $content["component"], $content["_editable"]); - } -} diff --git a/src/Story/StoryInterface.php b/src/Story/StoryInterface.php deleted file mode 100644 index 56fbd80..0000000 --- a/src/Story/StoryInterface.php +++ /dev/null @@ -1,18 +0,0 @@ -addCompilerPass(new CollectComponentDefinitionsCompilerPass()); + $container->registerForAutoconfiguration(AbstractComponent::class) ->addTag("storyblok.component.definition"); } diff --git a/src/Transformer/DataTransformer.php b/src/Transformer/DataTransformer.php index add078c..71dbf7c 100644 --- a/src/Transformer/DataTransformer.php +++ b/src/Transformer/DataTransformer.php @@ -5,25 +5,10 @@ final class DataTransformer { /** - * @template T - * - * @param T $data The transformed data of the component - * - * @returns T|null + * @param string|null $value + * @return string|null */ - public function transformValue ( - mixed $data, - mixed $component, - ) : mixed - { - // @todo add real implementation - return $data; - } - - /** - * - */ - public function normalizeOptionalString (?string $value) : ?string + public static function normalizeOptionalString (?string $value) : ?string { if (null === $value) { diff --git a/src/Visitor/ComponentDataVisitorInterface.php b/src/Visitor/ComponentDataVisitorInterface.php deleted file mode 100644 index 03e5e62..0000000 --- a/src/Visitor/ComponentDataVisitorInterface.php +++ /dev/null @@ -1,17 +0,0 @@ -