diff --git a/.gitignore b/.gitignore index fee334f..18a6ca4 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ composer.lock !var/log/.gitkeep /tools .phpactor.json +notes.md \ No newline at end of file diff --git a/config/services.yaml b/config/services.yaml index 83b7299..7651546 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -31,4 +31,21 @@ services: class: Atoolo\Resource\Loader\SiteKitResourceHierarchyLoader arguments: - '@atoolo_resource.cached_resource_loader' - - 'category' \ No newline at end of file + - 'category' + + # resource view system + + atoolo_resource.resource_view_factory: + class: Atoolo\Resource\ResourceViewFactory + arguments: + - !tagged_iterator resource_view.contributor + + atoolo_resource.resource_view_manager: + class: Atoolo\Resource\ResourceViewManager + arguments: + - '@atoolo_resource.resource_view_factory' + + atoolo_resource.resource_view_contributor: + class: Atoolo\Resource\ResourceViewContributor + tags: ['resource_view.contributor'] + diff --git a/src/Exception/MissingFeatureException.php b/src/Exception/MissingFeatureException.php new file mode 100644 index 0000000..9c14147 --- /dev/null +++ b/src/Exception/MissingFeatureException.php @@ -0,0 +1,28 @@ +loadRaw($location); @@ -54,7 +55,7 @@ public function load(ResourceLocation $location): Resource $resourceLang = ResourceLanguage::of($data['locale']); - return new Resource( + return new SiteKitResource( $location->location, (string) $data['id'], $data['name'], diff --git a/src/Model/Copyright.php b/src/Model/Copyright.php new file mode 100644 index 0000000..bbd138c --- /dev/null +++ b/src/Model/Copyright.php @@ -0,0 +1,8 @@ +, srcset) and essential metadata. + */ +final class Image +{ + /** + * @param string $url The primary, fallback URL for the src attribute. + * @param ImageSource[] $sources An array of alternative sources for different formats and resolutions. + * @param ?string $alt The essential alternative text for accessibility. + * @param ?int $width The intrinsic width of the fallback image to prevent layout shift. + * @param ?int $height The intrinsic height of the fallback image to prevent layout shift. + * @param ?Copyright $copyright Copyright information for the image. + * @param ?string $characteristic + * @param array $variants + */ + public function __construct( + public readonly string $url, + public readonly array $sources = [], + public readonly ?string $alt = null, + public readonly ?int $width = null, + public readonly ?int $height = null, + public readonly ?Copyright $copyright = null, + public readonly ?string $characteristic = null, + public readonly array $variants = [], // TODO + ) {} +} diff --git a/src/Model/Image/ImageSource.php b/src/Model/Image/ImageSource.php new file mode 100644 index 0000000..5d5d68c --- /dev/null +++ b/src/Model/Image/ImageSource.php @@ -0,0 +1,27 @@ + tag or an entry in a srcset attribute. + */ +final class ImageSource +{ + /** + * @param string $url The URL of this image version. + * @param ?string $mediaQuery The media query for art direction (e.g., '(min-width: 900px)'). + * @param ?string $mimeType The MIME type for different formats (e.g., 'image/webp'). + * @param ?int $width The width of this version in pixels. + * @param ?int $height The height of this version in pixels. + */ + public function __construct( + public readonly string $url, + public readonly ?string $mediaQuery = null, + public readonly ?string $mimeType = null, + public readonly ?int $width = null, + public readonly ?int $height = null, + ) {} +} diff --git a/src/Model/Link.php b/src/Model/Link.php new file mode 100644 index 0000000..418372e --- /dev/null +++ b/src/Model/Link.php @@ -0,0 +1,16 @@ + $additionalAttributes e.g. namespace specific attributes + */ + public function __construct( + public readonly string $title, + public readonly string $type, + public readonly OpenGraphImage $image, + public readonly string $url, + public readonly ?OpenGraphAudio $audio = null, + public readonly ?string $description = null, + public readonly ?string $determiner = null, + public readonly ?string $locale = null, + public readonly ?string $locale_alternate = null, + public readonly ?string $site_name = null, + public readonly ?OpenGraphVideo $video = null, + public readonly array $additionalAttributes = [], + ) {} +} diff --git a/src/Model/OpenGraph/OpenGraphImage.php b/src/Model/OpenGraph/OpenGraphImage.php new file mode 100644 index 0000000..a9047c8 --- /dev/null +++ b/src/Model/OpenGraph/OpenGraphImage.php @@ -0,0 +1,15 @@ +name = $name; + $this->objectType = $objectType; + $this->data = $data; + } + /** + * @deprecated This method will be made abstract in version 2.0.0. + * Use the class \SP\Resource\SiteKitResource if you want to keep using this method. + */ public function toLocation(): ResourceLocation { return ResourceLocation::of($this->location, $this->lang); diff --git a/src/ResourceFeature/ContentFeature.php b/src/ResourceFeature/ContentFeature.php new file mode 100644 index 0000000..0d0446c --- /dev/null +++ b/src/ResourceFeature/ContentFeature.php @@ -0,0 +1,18 @@ + + * @param ?string $description Meta description (150–160 chars recommended) + * @param ?string $canonicalUrl Optional canonical URL for SEO / deduplication. + * @param string[] $keywords Optional meta keywords + * @param RobotsDirective[] $robots Fine-grained robots directives (e.g. ["noindex", "nofollow", "noarchive"]) + */ + public function __construct( + public readonly string $title, + public readonly ?string $description = null, + public readonly ?string $canonicalUrl = null, + public readonly array $keywords = [], + public readonly array $robots = [], + ) {} + + public function isNoIndex(): bool + { + return in_array('noindex', $this->robots); + } +} diff --git a/src/ResourceFeature/SiteKitMetadataFeature.php b/src/ResourceFeature/SiteKitMetadataFeature.php new file mode 100644 index 0000000..cbd839c --- /dev/null +++ b/src/ResourceFeature/SiteKitMetadataFeature.php @@ -0,0 +1,17 @@ + $termsByTaxonomy An array where keys are taxonomy names (e.g., 'categories') + * and values are arrays of Term objects. + */ + public function __construct( + public readonly array $termsByTaxonomy = [], + ) {} +} diff --git a/src/ResourceFeature/TeaserFeature.php b/src/ResourceFeature/TeaserFeature.php new file mode 100644 index 0000000..8f9be27 --- /dev/null +++ b/src/ResourceFeature/TeaserFeature.php @@ -0,0 +1,18 @@ +, ResourceFeature> + */ + private array $resolved = []; + + /** + * @param array, callable():ResourceFeature> $factories + */ + public function __construct( + private array $factories, + ) {} + + /** + * Returns the feature of the given type, constructing it if necessary. + * Throws an error if the feature is unavailable. + * + * @template T of ResourceFeature + * @param class-string $feature Fully-qualified class name of the feature. + * @return T + * @throws MissingFeatureException if the feature is not registered. + * @throws \Throwable if feature construction fails. + */ + public function get(string $feature): ResourceFeature + { + $resolved = $this->tryGet($feature); + if ($resolved === null) { + throw new MissingFeatureException($feature, $this); + } + return $resolved; + } + + /** + * Returns the feature of the given type, constructing it if necessary. If unavailable, returns null. + * + * @template T of ResourceFeature + * @param class-string $feature + * @return T|null + */ + public function tryGet(string $feature): ?ResourceFeature + { + if (!isset($this->resolved[$feature]) && isset($this->factories[$feature])) { + $this->resolved[$feature] = ($this->factories[$feature])(); + } + /** @var ?T $resolved */ + $resolved = $this->resolved[$feature] ?? null; + return $resolved; + } + + /** + * Checks if the view contains a factory for the given feature type. + * + * @template T of ResourceFeature + * @param class-string $feature + */ + public function has(string $feature): bool + { + return isset($this->factories[$feature]); + } + + /** + * Returns true if the view has at least one of the given features. + * + * @template T of ResourceFeature + * @param class-string[] $features + */ + public function hasAny(array $features): bool + { + foreach ($features as $feature) { + if ($this->has($feature)) { + return true; + } + } + return false; + } + + /** + * Instantiates and resolves all registered features eagerly. + * Useful for early failure detection or warm-up. + */ + public function preloadAll(): void + { + foreach ($this->factories as $feature => $callback) { + if (isset($this->resolved[$feature])) { + continue; + } + try { + $this->resolved[$feature] = $callback(); + } catch (\Throwable $th) { + $this->logger?->warning('failed to preload resource feature', [ + 'feature' => $feature, + 'error' => $th, + ]); + } + } + } +} diff --git a/src/ResourceViewBatchContributor.php b/src/ResourceViewBatchContributor.php new file mode 100644 index 0000000..522d625 --- /dev/null +++ b/src/ResourceViewBatchContributor.php @@ -0,0 +1,20 @@ + $builders keyed by resource ID. + */ + public function contributeBatch(array $resources, array $builders): void; +} diff --git a/src/ResourceViewBuilder.php b/src/ResourceViewBuilder.php new file mode 100644 index 0000000..52ef171 --- /dev/null +++ b/src/ResourceViewBuilder.php @@ -0,0 +1,40 @@ +, ResourceViewBuilderBagEntry> + */ + private array $bag = []; + + /** + * @template T of ResourceFeature + * @param class-string $feature + * @param ResourceFeature|(callable():T) $value + */ + public function add(string $feature, ResourceFeature|callable $value, int $priority = 0): void + { + $cur = $this->bag[$feature] ?? null; + $factory = $value instanceof ResourceFeature + ? fn() => $value + : $value; + if ($cur === null || $priority > $cur->priority) { + $this->bag[$feature] = new ResourceViewBuilderBagEntry($priority, $factory); + } + } + + public function build(): ResourceView + { + $factories = []; + foreach ($this->bag as $class => $entry) { + $factories[$class] = $entry->factory; + } + return new ResourceView($factories); + } +} diff --git a/src/ResourceViewBuilderBagEntry.php b/src/ResourceViewBuilderBagEntry.php new file mode 100644 index 0000000..2f25fa4 --- /dev/null +++ b/src/ResourceViewBuilderBagEntry.php @@ -0,0 +1,17 @@ +factory = \Closure::fromCallable($factory); + } +} diff --git a/src/ResourceViewContributor.php b/src/ResourceViewContributor.php new file mode 100644 index 0000000..3c31147 --- /dev/null +++ b/src/ResourceViewContributor.php @@ -0,0 +1,23 @@ + $contributors + */ + public function __construct(private iterable $contributors) {} + + /** + * Creates a ResourceView for a given resource. + */ + public function createView(Resource $resource): ResourceView + { + $builder = new ResourceViewBuilder(); + foreach ($this->contributors as $contributor) { + if ($contributor->supports($resource)) { + $contributor->contribute($resource, $builder); + } + } + return $builder->build(); + } + + /** + * Builds ResourceViews for multiple resources in one pass. + * + * Contributors that implement ResourceViewBatchContributor + * can optimize batch creation + * + * @param Resource[] $resources + * @return array keyed by resource ID + */ + public function createViews(array $resources): array + { + $builders = []; + foreach ($resources as $r) { + $builders[$r->id] = new ResourceViewBuilder(); + } + foreach ($this->contributors as $contributor) { + $supportedResources = []; + $buildersForSupportedResources = []; + foreach ($resources as $r) { + if ($contributor->supports($r)) { + $supportedResources[] = $r; + $buildersForSupportedResources[$r->id] = $builders[$r->id]; + } + } + if (empty($supportedResources)) { + continue; + } + if ($contributor instanceof ResourceViewBatchContributor) { + $contributor->contributeBatch($supportedResources, $buildersForSupportedResources); + } else { + foreach ($supportedResources as $r) { + $contributor->contribute($r, $buildersForSupportedResources[$r->id]); + } + } + } + return array_map(fn($b) => $b->build(), $builders); + } +} diff --git a/src/ResourceViewManager.php b/src/ResourceViewManager.php new file mode 100644 index 0000000..0e54a5d --- /dev/null +++ b/src/ResourceViewManager.php @@ -0,0 +1,60 @@ + + */ + private array $cache = []; + + /** + * Retrieves the ResourceView for a given resource, creating and caching if needed. + */ + public function forResource(Resource $resource): ResourceView + { + return $this->cache[$resource->id] ??= $this->factory->createView($resource); + } + + /** + * Retrieves ResourceViews for multiple resources with caching. + * + * Already-cached views are reused, missing ones are created in batch. + * + * @param Resource[] $resources + * @return array keyed by resource ID + */ + public function forResources(array $resources): array + { + $cachedViews = []; + $uncachedResources = array_filter( + $resources, + function (Resource $resource) use (&$cachedViews) { + if (isset($this->cache[$resource->id])) { + $cachedViews[$resource->id] = $this->cache[$resource->id]; + return false; + } + return true; + }, + ); + $newViews = []; + if (!empty($uncachedResources)) { + $newViews = $this->factory->createViews($uncachedResources); + foreach ($newViews as $id => $view) { + $this->cache[$id] = $view; + } + } + return $cachedViews + $newViews; + } +} diff --git a/src/SiteKit/README.md b/src/SiteKit/README.md new file mode 100644 index 0000000..45279b5 --- /dev/null +++ b/src/SiteKit/README.md @@ -0,0 +1 @@ +This namespace should be moved into a separate atoolo-sitekit-bundle \ No newline at end of file diff --git a/src/SiteKit/SiteKitResource.php b/src/SiteKit/SiteKitResource.php new file mode 100644 index 0000000..ba1c52b --- /dev/null +++ b/src/SiteKit/SiteKitResource.php @@ -0,0 +1,42 @@ +location, $this->lang); + } +}