diff --git a/config/services.yaml b/config/services.yaml index 9689bf0..1f4bb1f 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -30,4 +30,9 @@ services: class: Atoolo\Resource\Loader\SiteKitResourceHierarchyLoader arguments: - '@atoolo_resource.cached_resource_loader' - - 'category' \ No newline at end of file + - 'category' + + atoolo_resource.resource_capability_factory: + class: Atoolo\Resource\ResourceCapabilityFactory + arguments: + - [] \ No newline at end of file diff --git a/src/AbstractResource.php b/src/AbstractResource.php new file mode 100644 index 0000000..2e9c331 --- /dev/null +++ b/src/AbstractResource.php @@ -0,0 +1,20 @@ +location, $this->lang); + } +} diff --git a/src/AtooloResourceBundle.php b/src/AtooloResourceBundle.php index 2e39ee4..0dafd13 100644 --- a/src/AtooloResourceBundle.php +++ b/src/AtooloResourceBundle.php @@ -4,11 +4,8 @@ namespace Atoolo\Resource; -use Symfony\Component\Config\FileLocator; -use Symfony\Component\Config\Loader\GlobFileLoader; -use Symfony\Component\Config\Loader\LoaderResolver; +use Atoolo\Resource\DependencyInjection\Compiler\ResourceCapabilityValidatorPass; use Symfony\Component\DependencyInjection\ContainerBuilder; -use Symfony\Component\DependencyInjection\Loader\YamlFileLoader; use Symfony\Component\HttpKernel\Bundle\Bundle; /** @@ -18,18 +15,7 @@ class AtooloResourceBundle extends Bundle { public function build(ContainerBuilder $container): void { - $configDir = __DIR__ . '/../config'; - - $locator = new FileLocator($configDir); - $loader = new GlobFileLoader($locator); - $loader->setResolver( - new LoaderResolver( - [ - new YamlFileLoader($container, $locator), - ], - ), - ); - - $loader->load('services.yaml'); + parent::build($container); + $container->addCompilerPass(new ResourceCapabilityValidatorPass()); } } diff --git a/src/Capabilities/MetadataCapability.php b/src/Capabilities/MetadataCapability.php new file mode 100644 index 0000000..f9d0ace --- /dev/null +++ b/src/Capabilities/MetadataCapability.php @@ -0,0 +1,73 @@ + tag. + * Crucial for SEO and browser tab text. + */ + public function getTitle(): string; + + /** + * Returns the content for the tag. + * Often used by search engines for the result snippet. + */ + public function getDescription(): string; + + /** + * Returns a list of keywords for the tag. + * @return string[] + */ + public function getKeywords(): array; + + /** + * Returns the name of the resource's author. + */ + public function getAuthor(): ?string; + + /** + * Returns the initial publication date. + */ + public function getPublishDate(): ?\DateTimeInterface; + + /** + * Returns the date the resource was last modified. + */ + public function getModifiedDate(): ?\DateTimeInterface; + + /** + * Returns the canonical URL to prevent duplicate content issues. + */ + public function getCanonicalUrl(): ?string; + + /** + * Determines if search engine robots should index this page. + */ + public function isIndexable(): bool; + + /** + * Determines if search engine robots should follow links on this page. + */ + public function isFollowable(): bool; + + /** + * Returns the title for social media sharing (Open Graph, Twitter Cards). + * Falls back to getTitle() if not specified. + */ + public function getSocialShareTitle(): string; + + /** + * Returns the description for social media sharing. + * Falls back to getDescription() if not specified. + */ + public function getSocialShareDescription(): string; + + /** + * Returns the URL for the social media sharing image. + */ + public function getSocialShareImageUrl(): ?string; +} diff --git a/src/Capabilities/SiteKitMetadataCapability.php b/src/Capabilities/SiteKitMetadataCapability.php new file mode 100644 index 0000000..0fa9c02 --- /dev/null +++ b/src/Capabilities/SiteKitMetadataCapability.php @@ -0,0 +1,86 @@ +data = $resource->data; + } + + public function getTitle(): string + { + return $this->data->getString('metadata.headline'); + } + + public function getDescription(): string + { + return $this->data->getString('metadata.description'); + } + + public function getKeywords(): array + { + /** @var string[] $keywords */ + $keywords = $this->data->getArray('metadata.keywords'); + return $keywords; + } + + public function getAuthor(): ?string + { + return null; // TODO + } + + public function getPublishDate(): ?\DateTimeInterface + { + return null; // TODO + } + + + public function getModifiedDate(): ?\DateTimeInterface + { + return null; // TODO + } + + public function getCanonicalUrl(): ?string + { + return null; // TODO + } + + + public function isIndexable(): bool + { + return true; // TODO + } + + public function isFollowable(): bool + { + return true; // TODO + } + + public function getSocialShareTitle(): string + { + return $this->getTitle(); // TODO + } + + public function getSocialShareDescription(): string + { + return $this->getDescription(); // TODO + } + + public function getSocialShareImageUrl(): ?string + { + return null; // TODO + } +} diff --git a/src/DependencyInjection/AtooloResourceExtension.php b/src/DependencyInjection/AtooloResourceExtension.php new file mode 100644 index 0000000..71f95be --- /dev/null +++ b/src/DependencyInjection/AtooloResourceExtension.php @@ -0,0 +1,48 @@ +processConfiguration($configuration, $configs); + $configDir = __DIR__ . '/../../config'; + $locator = new FileLocator($configDir); + $loader = new GlobFileLoader($locator); + $loader->setResolver( + new LoaderResolver( + [ + new YamlFileLoader($container, $locator), + ], + ), + ); + $loader->load('services.yaml'); + + $container->setParameter('atoolo_resource.resources', $config['resources']); + $this->initResourceCapabilityFactory($config, $container); + } + + // @phpstan-ignore-next-line + private function initResourceCapabilityFactory(array $config, ContainerBuilder $container): void + { + $capabilityImplementationMap = []; + + foreach ($config['resources'] as $resourceClass => $resourceDefinition) { + foreach ($resourceDefinition['capabilities'] as $capabilityInterface => $capabilityDefinition) { + $capabilityImplementationMap[$resourceClass][$capabilityInterface] + = $capabilityDefinition['implementation']; + } + } + $resourceCapabilityFactoryDef = $container->getDefinition('atoolo_resource.resource_capability_factory'); + $resourceCapabilityFactoryDef->setArgument('$capabilityImplementationMap', $capabilityImplementationMap); + } +} diff --git a/src/DependencyInjection/Compiler/ResourceCapabilityValidatorPass.php b/src/DependencyInjection/Compiler/ResourceCapabilityValidatorPass.php new file mode 100644 index 0000000..133ed59 --- /dev/null +++ b/src/DependencyInjection/Compiler/ResourceCapabilityValidatorPass.php @@ -0,0 +1,71 @@ +hasParameter('atoolo_resource.resources')) { + return; + } + + $resourceDefinitions = $container->getParameter('atoolo_resource.resources'); + // @phpstan-ignore-next-line + foreach ($resourceDefinitions as $resourceClass => $resourceDefinition) { + foreach ($resourceDefinition['capabilities'] as $capabilityInterface => $capabilityDefinition) { + + if (!interface_exists($capabilityInterface)) { + throw new LogicException(sprintf( + 'Configuration error: The capability interface "%s" requested for resource "%s" does not exist.', + $capabilityInterface, + $resourceClass, + )); + } + + $capabilityImplementation = $capabilityDefinition['implementation']; + + try { + $reflectionClass = new \ReflectionClass($capabilityImplementation); + $constructor = $reflectionClass->getConstructor(); + + + if ($constructor === null || $constructor->getNumberOfParameters() === 0) { + continue; // No constructor or no parameters, nothing to validate. + } + + $firstParam = $constructor->getParameters()[0]; + $requiredType = $firstParam->getType(); + + if ($requiredType === null || !$requiredType instanceof \ReflectionNamedType) { + continue; // No type-hint, cannot validate. + } + + $requiredResourceClass = $requiredType->getName(); + + // Check if the configured resource is the same as, or a subclass of, the required one. + if (!is_a($resourceClass, $requiredResourceClass, true)) { + throw new LogicException(sprintf( + 'Configuration error: The capability implementation "%s" requires ' + . 'a resource of type "%s", but is configured for "%s".', + $capabilityImplementation, + $requiredResourceClass, + $resourceClass, + )); + } + } catch (\ReflectionException $e) { + // Could not reflect on the class, might not exist. + throw new LogicException(sprintf( + 'Configuration error: The capability implementation "%s" requested for resource "%s" does not exist.', + $capabilityImplementation, + $resourceClass, + )); + } + } + } + } +} diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php new file mode 100644 index 0000000..d64292f --- /dev/null +++ b/src/DependencyInjection/Configuration.php @@ -0,0 +1,36 @@ +getRootNode() + ->children() + ->arrayNode('resources') + ->useAttributeAsKey('name') + ->arrayPrototype() + ->children() + ->arrayNode('capabilities') + ->useAttributeAsKey('interface') + ->arrayPrototype() + ->children() + ->scalarNode('implementation')->isRequired()->end() + ->end() + ->end() + ->end() + ->end() + ->end() + ->end() + ->end(); + + return $treeBuilder; + } +} diff --git a/src/Resource.php b/src/Resource.php index 389dce7..9dbd9a8 100644 --- a/src/Resource.php +++ b/src/Resource.php @@ -5,22 +5,19 @@ namespace Atoolo\Resource; /** - * In the Atoolo context, resources are aggregated data from + * In the Atoolo context, sitekit resources are aggregated data from * IES (Sitepark's content management system). */ -class Resource +class Resource extends AbstractResource { public function __construct( - public readonly string $location, - public readonly string $id, - public readonly string $name, + string $location, + string $id, + string $name, public readonly string $objectType, - public readonly ResourceLanguage $lang, + ResourceLanguage $lang, public readonly DataBag $data, - ) {} - - public function toLocation(): ResourceLocation - { - return ResourceLocation::of($this->location, $this->lang); + ) { + parent::__construct($location, $id, $name, $lang); } } diff --git a/src/ResourceCapabilityFactory.php b/src/ResourceCapabilityFactory.php new file mode 100644 index 0000000..6c52a60 --- /dev/null +++ b/src/ResourceCapabilityFactory.php @@ -0,0 +1,47 @@ +> $capabilityImplementationMap + */ + public function __construct( + private readonly array $capabilityImplementationMap, + ) {} + + /** + * @template T + * @param class-string $capabilityInterface + * @return T|null + */ + public function create(AbstractResource $resource, string $capabilityInterface): mixed + { + $resourceClass = get_class($resource); + + if (!isset($this->capabilityImplementationMap[$resourceClass])) { + $this->logger?->warning( + 'no resource config found for resource class', + [ + 'resource_class' => $resourceClass, + ], + ); + return null; + } + $capabilityImplementation = $this->capabilityImplementationMap[$resourceClass][$capabilityInterface] ?? null; + if ($capabilityImplementation === null) { + return null; + } + /** @var T $capabilityInstance */ + $capabilityInstance = new $capabilityImplementation($resource); + return $capabilityInstance; + } +} diff --git a/src/Service/ResourceCapabilityFactoryExampleService.php b/src/Service/ResourceCapabilityFactoryExampleService.php new file mode 100644 index 0000000..9931cd1 --- /dev/null +++ b/src/Service/ResourceCapabilityFactoryExampleService.php @@ -0,0 +1,32 @@ +resourceCapabilityFactory->create( + $resource, + MetadataCapability::class, + ); + + if ($metadata === null) { + echo "resource has no metadata"; + return; + } + + echo $metadata->getTitle(); + } +}