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);
+ }
+}