From 2d0b98416804d8a132895720c9c33e6061bd6752 Mon Sep 17 00:00:00 2001
From: Jake Vanderwerf <get@jakevanderwerf.ca>
Date: Mon, 09 Feb 2026 00:51:21 +0000
Subject: [PATCH] =Start of SEO Schema refactor, fixing Form.php upload and group fields
---
inc/managers/SEO/schemas/_setup.php | 26 +
inc/managers/SEO/schemas/SchemaResolverRegistry.php | 60 +++
inc/meta/Form.php | 3
inc/managers/SEO/schemas/resolvers/PersonResolver.php | 124 ++++++
inc/managers/SEO/_setup.php | 2
inc/managers/SEO/schemas/resolvers/CollectionPageResolver.php | 88 ++++
inc/managers/SEO/schemas/resolvers/BaseResolver.php | 228 ++++++++++++
inc/managers/SEO/schemas/resolvers/LocalBusinessResolver.php | 88 ++++
inc/managers/SEO/schemas/SchemaResolverInterface.php | 39 ++
inc/managers/SEO/SchemaOutputManager.php | 169 +--------
inc/managers/SEO/schemas/resolvers/VisualArtworkResolver.php | 146 ++++++++
inc/managers/SEO/schemas/SchemaDefinition.php | 59 +++
12 files changed, 884 insertions(+), 148 deletions(-)
diff --git a/inc/managers/SEO/SchemaOutputManager.php b/inc/managers/SEO/SchemaOutputManager.php
index ff062f4..d53da29 100644
--- a/inc/managers/SEO/SchemaOutputManager.php
+++ b/inc/managers/SEO/SchemaOutputManager.php
@@ -3,6 +3,7 @@
use JVBase\managers\Cache;
use JVBase\meta\Meta;
+use JVBase\managers\SEO\schemas\SchemaDefinition;
use WP_Term;
use WP_User;
@@ -384,10 +385,10 @@
$resolver = $this->getResolver();
$schemaType = $schemaConfig['type'];
- // Resolve all field values from templates
+ // Resolve templates (resolver handles transformation)
$resolvedConfig = $this->resolveConfigTemplates($schemaConfig, $resolver);
- // Build schema with resolved values
+ // Build via resolver system
$schema = $this->buildSchemaFromConfig(
$resolvedConfig,
$schemaType,
@@ -405,96 +406,30 @@
return $schema;
}
/**
- * Build schema for archive pages
- * Automatically generates mainEntity from archive posts
+ * Build schema for archive pages.
+ * mainEntity is now handled by CollectionPageResolver::getAutoFields()
*/
private function buildArchiveSchema(array $context): ?array
{
- // Ensure archive config is initialized
if (!$this->config->archive()) {
$this->config->setupArchive();
}
$archiveConfig = $this->config->archive();
- // Return null if no config or no type defined
if (empty($archiveConfig) || empty($archiveConfig['type'])) {
return null;
}
$resolver = $this->getResolver();
- $schemaType = $archiveConfig['type'];
-
- // Resolve templates from archive config
$resolvedConfig = $this->resolveConfigTemplates($archiveConfig, $resolver);
- // Build base schema
- $schema = $this->buildSchemaFromConfig(
+ // Resolver handles mainEntity auto-enrichment now
+ return $this->buildSchemaFromConfig(
$resolvedConfig,
- $schemaType,
- $resolver->resolveVariable('permalink') . '#' . strtolower($schemaType)
+ $archiveConfig['type'],
+ $resolver->resolveVariable('permalink') . '#' . strtolower($archiveConfig['type'])
);
-
- if (!$schema) {
- return null;
- }
-
- // Automatically add mainEntity for types that need it
- $mainEntity = $this->buildMainEntity($schemaType, $context['type']);
- if ($mainEntity) {
- $schema['mainEntity'] = $mainEntity;
- }
-
- return $schema;
- }
-
- /**
- * Automatically build mainEntity for archive pages
- * Uses SchemaReferenceBuilder to generate entities from archive posts
- *
- * @param string $archiveSchemaType The archive's @type (FAQPage, CollectionPage, etc.)
- * @param string $contentType The content type being archived (faq, artwork, etc.)
- * @return array|null Array of entities or null if not applicable
- */
- private function buildMainEntity(string $archiveSchemaType, string $contentType): ?array
- {
- // Only certain archive types need mainEntity
- $typesNeedingMainEntity = ['FAQPage', 'CollectionPage', 'ItemList'];
- if (!in_array($archiveSchemaType, $typesNeedingMainEntity)) {
- return null;
- }
-
- $context = $this->getCurrentContext();
-
- // For taxonomy term archives, get posts from the term
- if ($context['objectType'] === 'term') {
- // Get the post type(s) this taxonomy is for
- $taxonomy = defined('JVB_TAXONOMY') && isset(JVB_TAXONOMY[$contentType])
- ? JVB_TAXONOMY[$contentType]
- : null;
-
- if (!$taxonomy || empty($taxonomy['for_content'])) {
- return null;
- }
-
- // Use the first post type (most common case)
- $postType = $taxonomy['for_content'][0];
-
- return SchemaReferenceBuilder::buildFromTerm(
- $context['objectId'],
- $postType,
- 10, // limit
- null, // auto-infer type
- true // include context
- );
- }
-
- // For post type archives
- if ($context['objectType'] === 'archive') {
- return SchemaReferenceBuilder::buildFromArchive($contentType);
- }
-
- return null;
}
/**
@@ -520,86 +455,26 @@
}
/**
- * Enhanced buildSchemaFromConfig with Meta integration
+ * Build schema from config using the resolver system.
+ *
+ * Replaces the old double-transform approach with a single-pass
+ * resolver that handles template resolution and transformation.
*/
private function buildSchemaFromConfig(array $config, string $schemaType, ?string $id = null): ?array
{
- // Build base schema
- $schema = ['@type' => $this->resolveSchemaType($schemaType)];
-
- if ($id) {
- $schema['@id'] = $id;
- }
-
- // Get Meta if we have a context
- $meta = null;
$context = $this->getCurrentContext();
- if ($context) {
- $meta = new Meta($context['objectId'], $context['objectType']);
- }
- // Process each field
- foreach ($config as $fieldName => $value) {
- // Skip meta fields and empty values
- if ($fieldName === 'type' || $value === null || $value === '' || $value === []) {
- continue;
- }
+ $definition = SchemaDefinition::fromContext(
+ $this->resolveSchemaType($schemaType),
+ $config,
+ $id,
+ $context
+ );
- // Auto-resolve field value (handles images, locations, etc.)
- $value = SchemaFieldHelpers::autoResolve($fieldName, $value, $meta);
+ $resolver = SchemaResolverRegistry::getInstance()->get($schemaType);
+ $meta = $context ? new Meta($context['objectId'], $context['objectType']) : null;
- // Get field definition for transformer
- $fieldDef = $this->registry->getFieldDefinition($fieldName);
-
- // Apply transformer if defined
- if ($fieldDef && !empty($fieldDef['transformer'])) {
- $value = $this->applyTransformer($value, $fieldDef['transformer'], $fieldName);
- }
-
- // Skip if empty after transformation
- if ($value === null || $value === '' || $value === []) {
- continue;
- }
-
- // Handle multi-property transformers (like location_complex returns address + geo)
- if (is_array($value) && !isset($value['@type']) && !isset($value[0])) {
- $multiProps = ['address', 'geo', 'openingHours', 'sameAs'];
- if (!empty(array_intersect(array_keys($value), $multiProps))) {
- foreach ($value as $subKey => $subValue) {
- if ($subValue !== null && $subValue !== '' && $subValue !== []) {
- $schema[$subKey] = $subValue;
- }
- }
- continue;
- }
- }
-
- // Normal case: add single property
- $schema[$fieldName] = $value;
- }
-
- // Return null if only @type remains
- return (count($schema) > 1) ? $schema : null;
- }
-
- /**
- * Apply transformer to a field value
- */
- private function applyTransformer(mixed $value, string $transformer, string $fieldName): mixed
- {
- // Check if transformer method exists in SchemaFieldHelpers
- if (method_exists(SchemaFieldHelpers::class, $transformer)) {
- try {
- return SchemaFieldHelpers::$transformer($value);
- } catch (\Throwable $e) {
- // Log error but don't break schema output
- error_log("Schema transformer error for {$fieldName}: {$e->getMessage()}");
- return $value;
- }
- }
-
- // No transformer found, return value as-is
- return $value;
+ return $resolver->resolve($definition, $meta);
}
/**
diff --git a/inc/managers/SEO/_setup.php b/inc/managers/SEO/_setup.php
index 517bd54..29f48f1 100644
--- a/inc/managers/SEO/_setup.php
+++ b/inc/managers/SEO/_setup.php
@@ -1,4 +1,6 @@
<?php
+require(JVB_DIR . '/inc/managers/SEO/schemas/_setup.php');
+
require(JVB_DIR . '/inc/managers/SEO/FieldBuilder.php');
require(JVB_DIR . '/inc/managers/SEO/FieldOverrideBuilder.php');
require(JVB_DIR . '/inc/managers/SEO/TypeBuilder.php');
diff --git a/inc/managers/SEO/schemas/SchemaDefinition.php b/inc/managers/SEO/schemas/SchemaDefinition.php
new file mode 100644
index 0000000..d7f08f3
--- /dev/null
+++ b/inc/managers/SEO/schemas/SchemaDefinition.php
@@ -0,0 +1,59 @@
+<?php
+namespace JVBase\managers\SEO\schemas;
+
+if (!defined('ABSPATH')) {
+ exit;
+}
+
+/**
+ * Value object representing a schema entity to be resolved.
+ *
+ * Like Operation.php holds the data for a queue item,
+ * this holds the data for a schema entity being built.
+ */
+final class SchemaDefinition
+{
+ /** @var string Schema.org type (e.g., 'TattooParlor', 'VisualArtwork') */
+ public string $schemaType = '';
+
+ /** @var string|null JSON-LD @id (e.g., 'https://site.com/#organization') */
+ public ?string $id = null;
+
+ /** @var int|null WordPress object ID (post ID, term ID, user ID) */
+ public ?int $objectId = null;
+
+ /** @var string|null Object type: post, term, user, archive */
+ public ?string $objectType = null;
+
+ /** @var string|null Content type slug from constants (tattoo, artist, shop) */
+ public ?string $contentType = null;
+
+ /** @var array Saved SEO config from ConfigManager */
+ public array $config = [];
+
+ /** @var array Additional context (archive type, parent term, etc.) */
+ public array $context = [];
+
+ /**
+ * Create from the current SchemaOutputManager context
+ */
+ public static function fromContext(
+ string $schemaType,
+ array $config,
+ ?string $id = null,
+ ?array $wpContext = null
+ ): self {
+ $def = new self();
+ $def->schemaType = $schemaType;
+ $def->config = $config;
+ $def->id = $id;
+
+ if ($wpContext) {
+ $def->objectId = $wpContext['objectId'] ?? null;
+ $def->objectType = $wpContext['objectType'] ?? null;
+ $def->contentType = $wpContext['type'] ?? null;
+ }
+
+ return $def;
+ }
+}
diff --git a/inc/managers/SEO/schemas/SchemaResolverInterface.php b/inc/managers/SEO/schemas/SchemaResolverInterface.php
new file mode 100644
index 0000000..7944ba6
--- /dev/null
+++ b/inc/managers/SEO/schemas/SchemaResolverInterface.php
@@ -0,0 +1,39 @@
+<?php
+namespace JVBase\managers\SEO\schemas;
+
+use JVBase\meta\Meta;
+
+if (!defined('ABSPATH')) {
+ exit;
+}
+
+/**
+ * Interface for type-specific schema resolution.
+ *
+ * Like the Queue's Executor interface, each resolver
+ * knows how to build schema for its content type family.
+ */
+interface SchemaResolverInterface
+{
+ /**
+ * Resolve all fields and return the complete schema array for JSON-LD.
+ *
+ * @param SchemaDefinition $definition Schema definition with config and context
+ * @param Meta|null $meta Optional Meta instance for field lookups
+ * @return array|null Complete schema array, or null if empty
+ */
+ public function resolve(SchemaDefinition $definition, ?Meta $meta = null): ?array;
+
+ /**
+ * Get auto-enrichment fields beyond what's in config.
+ *
+ * Allows type-specific intelligence like:
+ * - TattooParlor auto-includes artists as `employee`
+ * - VisualArtwork derives `artform` from taxonomy terms
+ * - Person adds `worksFor` from shop association
+ *
+ * @param SchemaDefinition $definition Schema definition with context
+ * @return array Field name => value pairs to merge (won't override config)
+ */
+ public function getAutoFields(SchemaDefinition $definition): array;
+}
diff --git a/inc/managers/SEO/schemas/SchemaResolverRegistry.php b/inc/managers/SEO/schemas/SchemaResolverRegistry.php
new file mode 100644
index 0000000..797d425
--- /dev/null
+++ b/inc/managers/SEO/schemas/SchemaResolverRegistry.php
@@ -0,0 +1,60 @@
+<?php
+namespace JVBase\managers\SEO;
+
+use JVBase\managers\SEO\schemas\SchemaResolverInterface;
+use JVBase\managers\SEO\schemas\resolvers\BaseResolver;
+
+/**
+ * Registry mapping schema types to their resolvers.
+ * Like Queue's TypeRegistry maps operation types to executors.
+ */
+class SchemaResolverRegistry
+{
+ private static ?self $instance = null;
+ private array $resolvers = [];
+ private BaseResolver $default;
+
+ private function __construct()
+ {
+ $this->default = new BaseResolver();
+ $this->registerDefaults();
+
+ do_action(BASE . 'schema_resolvers_loaded', $this);
+ }
+
+ public static function getInstance(): self
+ {
+ if (self::$instance === null) {
+ self::$instance = new self();
+ }
+ return self::$instance;
+ }
+
+ public function register(string $schemaType, SchemaResolverInterface $resolver): void
+ {
+ $this->resolvers[$schemaType] = $resolver;
+ }
+
+ public function get(string $schemaType): SchemaResolverInterface
+ {
+ // Check exact match, then parent chain
+ if (isset($this->resolvers[$schemaType])) {
+ return $this->resolvers[$schemaType];
+ }
+
+ // Check parent type (TattooParlor → LocalBusiness → Organization)
+ $builder = SchemaBuilder::getInstance();
+ $typeDef = $builder->getTypeDefinition($schemaType);
+ if ($typeDef && !empty($typeDef['extends'])) {
+ return $this->get($typeDef['extends']);
+ }
+
+ return $this->default;
+ }
+
+ private function registerDefaults(): void
+ {
+ // Register type-specific resolvers
+ // Extensible via the action hook above
+ }
+}
diff --git a/inc/managers/SEO/schemas/_setup.php b/inc/managers/SEO/schemas/_setup.php
new file mode 100644
index 0000000..6f4e51c
--- /dev/null
+++ b/inc/managers/SEO/schemas/_setup.php
@@ -0,0 +1,26 @@
+<?php
+/**
+ * Schema Resolver System
+ *
+ * Loads the resolver interface, definition value object,
+ * and all concrete resolver implementations.
+ *
+ * Include this from the parent SEO _setup.php or SchemaOutputManager.
+ */
+
+if (!defined('ABSPATH')) {
+ exit;
+}
+
+// Core
+require(JVB_DIR . '/inc/managers/SEO/schemas/SchemaDefinition.php');
+require(JVB_DIR . '/inc/managers/SEO/schemas/SchemaResolverInterface.php');
+
+// Resolvers
+require(JVB_DIR . '/inc/managers/SEO/schemas/resolvers/BaseResolver.php');
+require(JVB_DIR . '/inc/managers/SEO/schemas/resolvers/LocalBusinessResolver.php');
+require(JVB_DIR . '/inc/managers/SEO/schemas/resolvers/VisualArtworkResolver.php');
+require(JVB_DIR . '/inc/managers/SEO/schemas/resolvers/PersonResolver.php');
+require(JVB_DIR . '/inc/managers/SEO/schemas/resolvers/CollectionPageResolver.php');
+
+require(JVB_DIR . '/inc/managers/SEO/schemas/_setup.php');
diff --git a/inc/managers/SEO/schemas/resolvers/BaseResolver.php b/inc/managers/SEO/schemas/resolvers/BaseResolver.php
new file mode 100644
index 0000000..68dc297
--- /dev/null
+++ b/inc/managers/SEO/schemas/resolvers/BaseResolver.php
@@ -0,0 +1,228 @@
+<?php
+namespace JVBase\managers\SEO\schemas\resolvers;
+
+use JVBase\managers\SEO\schemas\SchemaDefinition;
+use JVBase\managers\SEO\schemas\SchemaResolverInterface;
+use JVBase\managers\SEO\SchemaBuilder;
+use JVBase\managers\SEO\SchemaFieldHelpers;
+use JVBase\managers\SEO\TemplateResolver;
+use JVBase\meta\Meta;
+
+if (!defined('ABSPATH')) {
+ exit;
+}
+
+/**
+ * Default schema resolver - handles generic field resolution.
+ *
+ * Type-specific resolvers extend this and override:
+ * - getAutoFields() for auto-enrichment
+ * - resolveField() for custom field handling
+ * - transformField() for custom transformations
+ *
+ * Single transformation pass (fixes the double-transform bug).
+ */
+class BaseResolver implements SchemaResolverInterface
+{
+ protected ?TemplateResolver $templateResolver = null;
+ protected ?SchemaBuilder $builder = null;
+
+ /**
+ * Properties that multi-property transformers expand into
+ * (e.g., location_complex returns both 'address' and 'geo')
+ */
+ private const MULTI_PROPS = ['address', 'geo', 'openingHours', 'sameAs'];
+
+ public function resolve(SchemaDefinition $definition, ?Meta $meta = null): ?array
+ {
+ $schema = ['@type' => $definition->schemaType];
+
+ if ($definition->id) {
+ $schema['@id'] = $definition->id;
+ }
+
+ $this->builder = SchemaBuilder::getInstance();
+ $this->templateResolver = $this->buildTemplateResolver($definition);
+
+ // Resolve each config field (single pass)
+ foreach ($definition->config as $fieldName => $value) {
+ if ($fieldName === 'type' || $this->isEmpty($value)) {
+ continue;
+ }
+
+ $resolved = $this->resolveField($fieldName, $value, $meta);
+
+ if (!$this->isEmpty($resolved)) {
+ $schema = $this->mergeField($schema, $fieldName, $resolved);
+ }
+ }
+
+ // Auto-enrich with type-specific fields (won't override existing)
+ foreach ($this->getAutoFields($definition) as $fieldName => $value) {
+ if (!isset($schema[$fieldName]) && !$this->isEmpty($value)) {
+ $schema[$fieldName] = $value;
+ }
+ }
+
+ return count($schema) > 1 ? $schema : null;
+ }
+
+ /**
+ * Override in subclasses to add type-specific auto-fields.
+ */
+ public function getAutoFields(SchemaDefinition $definition): array
+ {
+ return [];
+ }
+
+ /**
+ * Resolve a single field value.
+ *
+ * Template resolution → transformation in one pass.
+ * Subclasses can override for specific field handling.
+ */
+ protected function resolveField(string $fieldName, mixed $value, ?Meta $meta): mixed
+ {
+ // Resolve template patterns ({{post_title}}, etc.)
+ $value = $this->resolveTemplates($value);
+
+ if ($value === null) {
+ return null;
+ }
+
+ // Single transformation pass
+ return $this->transformField($fieldName, $value, $meta);
+ }
+
+ /**
+ * Resolve template patterns in a value (string or nested array).
+ *
+ * @return mixed Resolved value, or null if unresolvable pattern
+ */
+ protected function resolveTemplates(mixed $value): mixed
+ {
+ if (is_string($value) && SchemaFieldHelpers::isPattern($value)) {
+ $resolved = $this->templateResolver?->resolve($value);
+
+ // Unresolved pattern → skip
+ if ($resolved === null || SchemaFieldHelpers::isPattern($resolved)) {
+ return null;
+ }
+
+ return $resolved !== '' ? $resolved : null;
+ }
+
+ // Recurse into arrays
+ if (is_array($value)) {
+ $resolved = [];
+ foreach ($value as $key => $subValue) {
+ $sub = $this->resolveTemplates($subValue);
+ if ($sub !== null) {
+ $resolved[$key] = $sub;
+ }
+ }
+ return !empty($resolved) ? $resolved : null;
+ }
+
+ return $value;
+ }
+
+ /**
+ * Transform a field value using its registered transformer.
+ *
+ * This is the SINGLE transformation pass (fixes the old double-transform bug
+ * where autoResolve ran first, then the transformer ran again).
+ *
+ * Override in subclasses to handle specific fields differently.
+ */
+ protected function transformField(string $fieldName, mixed $value, ?Meta $meta): mixed
+ {
+ // Already transformed (has @type) → return as-is
+ if (is_array($value) && isset($value['@type'])) {
+ return $value;
+ }
+
+ // Get transformer from field definition
+ $fieldDef = $this->builder?->getFieldDefinition($fieldName);
+
+ if ($fieldDef && !empty($fieldDef['transformer'])) {
+ return $this->applyTransformer($value, $fieldDef['transformer'], $fieldName);
+ }
+
+ // Fall back to auto-resolve for fields without explicit transformers
+ return SchemaFieldHelpers::autoResolve($fieldName, $value, $meta);
+ }
+
+ /**
+ * Apply a named transformer from SchemaFieldHelpers.
+ */
+ protected function applyTransformer(mixed $value, string $transformer, string $fieldName): mixed
+ {
+ if (!method_exists(SchemaFieldHelpers::class, $transformer)) {
+ return $value;
+ }
+
+ try {
+ return SchemaFieldHelpers::$transformer($value);
+ } catch (\Throwable $e) {
+ error_log("Schema transformer error for {$fieldName}: {$e->getMessage()}");
+ return $value;
+ }
+ }
+
+ /**
+ * Merge a resolved field into the schema array.
+ *
+ * Handles multi-property returns (e.g., location_complex → address + geo).
+ */
+ protected function mergeField(array $schema, string $fieldName, mixed $value): array
+ {
+ // Check for multi-property expansion
+ if (is_array($value) && !isset($value['@type']) && !isset($value[0])) {
+ if (!empty(array_intersect(array_keys($value), self::MULTI_PROPS))) {
+ foreach ($value as $subKey => $subValue) {
+ if (!$this->isEmpty($subValue)) {
+ $schema[$subKey] = $subValue;
+ }
+ }
+ return $schema;
+ }
+ }
+
+ $schema[$fieldName] = $value;
+ return $schema;
+ }
+
+ /**
+ * Build the TemplateResolver for this definition's context.
+ */
+ protected function buildTemplateResolver(SchemaDefinition $definition): ?TemplateResolver
+ {
+ if ($definition->objectId && $definition->objectType) {
+ return new TemplateResolver(
+ $definition->objectId,
+ $definition->objectType,
+ $definition->contentType
+ );
+ }
+
+ return TemplateResolver::forCurrentObject();
+ }
+
+ /**
+ * Build a Meta instance for the definition's context.
+ */
+ protected function buildMeta(SchemaDefinition $definition): ?Meta
+ {
+ if (!$definition->objectId || !$definition->objectType) {
+ return null;
+ }
+
+ return new Meta($definition->objectId, $definition->objectType);
+ }
+
+ protected function isEmpty(mixed $value): bool
+ {
+ return $value === null || $value === '' || $value === [];
+ }
+}
diff --git a/inc/managers/SEO/schemas/resolvers/CollectionPageResolver.php b/inc/managers/SEO/schemas/resolvers/CollectionPageResolver.php
new file mode 100644
index 0000000..90fe65a
--- /dev/null
+++ b/inc/managers/SEO/schemas/resolvers/CollectionPageResolver.php
@@ -0,0 +1,88 @@
+<?php
+namespace JVBase\managers\SEO\schemas\resolvers;
+
+use JVBase\managers\SEO\schemas\SchemaDefinition;
+use JVBase\managers\SEO\SchemaReferenceBuilder;
+
+if (!defined('ABSPATH')) {
+ exit;
+}
+
+/**
+ * Resolver for archive/collection page schemas.
+ *
+ * Handles: CollectionPage, FAQPage, DefinedTermSet, ItemList
+ *
+ * Auto-enrichment:
+ * - Builds `mainEntity` from archive posts or term's associated posts
+ * - Adds `numberOfItems` for ItemList
+ */
+class CollectionPageResolver extends BaseResolver
+{
+ /** Schema types that should get mainEntity from their posts */
+ private const ENTITY_TYPES = ['FAQPage', 'CollectionPage', 'ItemList', 'DefinedTermSet'];
+
+ public function getAutoFields(SchemaDefinition $definition): array
+ {
+ $fields = [];
+
+ if (!in_array($definition->schemaType, self::ENTITY_TYPES)) {
+ return $fields;
+ }
+
+ $mainEntity = $this->buildMainEntity($definition);
+
+ if (!empty($mainEntity)) {
+ $fields['mainEntity'] = $mainEntity;
+ }
+
+ return $fields;
+ }
+
+ /**
+ * Build mainEntity from the archive's posts.
+ */
+ private function buildMainEntity(SchemaDefinition $definition): ?array
+ {
+ // Term archive (e.g., /tattoo-style/american-traditional/)
+ if ($definition->objectType === 'term' && $definition->objectId) {
+ return $this->buildFromTerm($definition);
+ }
+
+ // Post type archive (e.g., /tattoos/)
+ if ($definition->objectType === 'archive' && $definition->contentType) {
+ return SchemaReferenceBuilder::buildFromArchive($definition->contentType);
+ }
+
+ return null;
+ }
+
+ /**
+ * Build mainEntity from a taxonomy term's associated posts.
+ */
+ private function buildFromTerm(SchemaDefinition $definition): ?array
+ {
+ if (!defined('JVB_TAXONOMY') || !$definition->contentType) {
+ return null;
+ }
+
+ $slug = jvbNoBase($definition->contentType);
+ $taxConfig = JVB_TAXONOMY[$slug] ?? [];
+
+ if (empty($taxConfig['for_content'])) {
+ return null;
+ }
+
+ // Use the first associated post type
+ $postType = $taxConfig['for_content'][0];
+ $fullType = str_starts_with($postType, BASE) ? $postType : BASE . $postType;
+
+ return SchemaReferenceBuilder::buildFromTerm(
+ $definition->objectId,
+ $fullType,
+ 10,
+ null,
+ true
+ );
+ }
+}
diff --git a/inc/managers/SEO/schemas/resolvers/LocalBusinessResolver.php b/inc/managers/SEO/schemas/resolvers/LocalBusinessResolver.php
new file mode 100644
index 0000000..6cc7045
--- /dev/null
+++ b/inc/managers/SEO/schemas/resolvers/LocalBusinessResolver.php
@@ -0,0 +1,88 @@
+<?php
+namespace JVBase\managers\SEO\schemas\resolvers;
+
+use JVBase\managers\SEO\schemas\SchemaDefinition;
+use JVBase\managers\SEO\SchemaReferenceBuilder;
+use JVBase\meta\Meta;
+
+if (!defined('ABSPATH')) {
+ exit;
+}
+
+/**
+ * Resolver for LocalBusiness and its children (TattooParlor, FoodEstablishment, etc.)
+ *
+ * Auto-enrichment:
+ * - Adds `employee` from associated content types (artists for a shop)
+ * - Adds `makesOffer` from linked post types (services, styles)
+ * - Resolves location from term meta if objectType is 'term'
+ */
+class LocalBusinessResolver extends BaseResolver
+{
+ public function getAutoFields(SchemaDefinition $definition): array
+ {
+ $fields = [];
+
+ if (!$definition->objectId || !$definition->contentType) {
+ return $fields;
+ }
+
+ $taxConfig = $this->getTaxonomyConfig($definition->contentType);
+
+ if (empty($taxConfig)) {
+ return $fields;
+ }
+
+ // Auto-include employees from associated content types
+ if (!empty($taxConfig['for_content'])) {
+ $employees = $this->buildEmployeeRefs($definition, $taxConfig['for_content']);
+ if (!empty($employees)) {
+ $fields['employee'] = $employees;
+ }
+ }
+
+ return $fields;
+ }
+
+ /**
+ * Build Person references for employees from associated post types.
+ *
+ * For a tattoo shop term, this finds all artists (posts) tagged with this shop.
+ */
+ private function buildEmployeeRefs(SchemaDefinition $definition, array $postTypes): array
+ {
+ $allRefs = [];
+
+ foreach ($postTypes as $postType) {
+ $fullType = str_starts_with($postType, BASE) ? $postType : BASE . $postType;
+
+ $refs = SchemaReferenceBuilder::buildFromTerm(
+ $definition->objectId,
+ $fullType,
+ 10,
+ null,
+ true
+ );
+
+ if (!empty($refs)) {
+ $allRefs = array_merge($allRefs, $refs);
+ }
+ }
+
+ return $allRefs;
+ }
+
+ /**
+ * Get taxonomy config from JVB_TAXONOMY constant.
+ */
+ private function getTaxonomyConfig(string $contentType): array
+ {
+ if (!defined('JVB_TAXONOMY')) {
+ return [];
+ }
+
+ $slug = jvbNoBase($contentType);
+
+ return JVB_TAXONOMY[$slug] ?? [];
+ }
+}
diff --git a/inc/managers/SEO/schemas/resolvers/PersonResolver.php b/inc/managers/SEO/schemas/resolvers/PersonResolver.php
new file mode 100644
index 0000000..cd9d95c
--- /dev/null
+++ b/inc/managers/SEO/schemas/resolvers/PersonResolver.php
@@ -0,0 +1,124 @@
+<?php
+namespace JVBase\managers\SEO\schemas\resolvers;
+
+use JVBase\managers\SEO\schemas\SchemaDefinition;
+use JVBase\managers\SEO\SchemaReferenceBuilder;
+use JVBase\meta\Meta;
+
+if (!defined('ABSPATH')) {
+ exit;
+}
+
+/**
+ * Resolver for Person schema (artists, staff, authors).
+ *
+ * Auto-enrichment:
+ * - Adds `worksFor` from shop/organization association
+ * - Adds `makesOffer` / sample `workExample` from their content
+ * - Derives `knowsAbout` from their content's taxonomy terms
+ */
+class PersonResolver extends BaseResolver
+{
+ public function getAutoFields(SchemaDefinition $definition): array
+ {
+ $fields = [];
+
+ if (!$definition->objectId) {
+ return $fields;
+ }
+
+ // For user-type Person schemas (author pages)
+ if ($definition->objectType === 'user') {
+ $worksFor = $this->buildWorksFor($definition->objectId);
+ if (!empty($worksFor)) {
+ $fields['worksFor'] = $worksFor;
+ }
+
+ $examples = $this->buildWorkExamples($definition->objectId);
+ if (!empty($examples)) {
+ $fields['workExample'] = $examples;
+ }
+ }
+
+ return $fields;
+ }
+
+ /**
+ * Find Organization/LocalBusiness the person works for.
+ *
+ * Checks taxonomy terms assigned to the user (e.g., shop terms).
+ */
+ private function buildWorksFor(int $userId): ?array
+ {
+ if (!defined('JVB_TAXONOMY')) {
+ return null;
+ }
+
+ // Find taxonomies that represent organizations (is_content taxonomies)
+ foreach (JVB_TAXONOMY as $slug => $config) {
+ if (empty($config['is_content'])) {
+ continue;
+ }
+
+ $fullTax = BASE . $slug;
+ $terms = wp_get_object_terms($userId, $fullTax);
+
+ if (is_wp_error($terms) || empty($terms)) {
+ continue;
+ }
+
+ // Return first associated organization
+ $term = $terms[0];
+
+ return SchemaReferenceBuilder::build(
+ $term->term_id,
+ 'term',
+ null
+ );
+ }
+
+ return null;
+ }
+
+ /**
+ * Build work examples from the person's authored content.
+ */
+ private function buildWorkExamples(int $userId, int $limit = 5): array
+ {
+ if (!defined('JVB_CONTENT')) {
+ return [];
+ }
+
+ $examples = [];
+
+ foreach (JVB_CONTENT as $slug => $config) {
+ $fullType = BASE . $slug;
+
+ $posts = get_posts([
+ 'post_type' => $fullType,
+ 'author' => $userId,
+ 'posts_per_page' => $limit,
+ 'post_status' => 'publish',
+ 'fields' => 'ids',
+ ]);
+
+ if (empty($posts)) {
+ continue;
+ }
+
+ $refs = SchemaReferenceBuilder::buildMultiple($posts, 'post', null, true);
+
+ if (!empty($refs)) {
+ $examples = array_merge($examples, $refs);
+ }
+
+ // Cap total examples
+ if (count($examples) >= $limit) {
+ $examples = array_slice($examples, 0, $limit);
+ break;
+ }
+ }
+
+ return $examples;
+ }
+}
diff --git a/inc/managers/SEO/schemas/resolvers/VisualArtworkResolver.php b/inc/managers/SEO/schemas/resolvers/VisualArtworkResolver.php
new file mode 100644
index 0000000..1b7d833
--- /dev/null
+++ b/inc/managers/SEO/schemas/resolvers/VisualArtworkResolver.php
@@ -0,0 +1,146 @@
+<?php
+namespace JVBase\managers\SEO\schemas\resolvers;
+
+use JVBase\managers\SEO\schemas\SchemaDefinition;
+use JVBase\managers\SEO\SchemaFieldHelpers;
+use JVBase\managers\SEO\SchemaReferenceBuilder;
+use JVBase\meta\Meta;
+
+if (!defined('ABSPATH')) {
+ exit;
+}
+
+/**
+ * Resolver for VisualArtwork and its children (Tattoo, etc.)
+ *
+ * Auto-enrichment:
+ * - Derives `artform` from taxonomy terms (tattoo-style → "American Traditional")
+ * - Derives `artMedium` from taxonomy terms (tattoo-colour → "Black and Grey")
+ * - Adds `creator` as Person reference from post author
+ * - Adds `keywords` from associated taxonomy terms
+ */
+class VisualArtworkResolver extends BaseResolver
+{
+ public function getAutoFields(SchemaDefinition $definition): array
+ {
+ $fields = [];
+
+ if (!$definition->objectId || $definition->objectType !== 'post') {
+ return $fields;
+ }
+
+ $contentConfig = $this->getContentConfig($definition->contentType);
+
+ if (empty($contentConfig)) {
+ return $fields;
+ }
+
+ // Derive artform from style taxonomy terms
+ $artform = $this->deriveFromTaxonomy($definition->objectId, $contentConfig, 'style');
+ if (!empty($artform)) {
+ $fields['artform'] = count($artform) === 1 ? $artform[0] : $artform;
+ }
+
+ // Derive artMedium from medium/colour taxonomy terms
+ $medium = $this->deriveFromTaxonomy($definition->objectId, $contentConfig, 'colour');
+ if (!empty($medium)) {
+ $fields['artMedium'] = count($medium) === 1 ? $medium[0] : $medium;
+ }
+
+ // Add creator from post author
+ $creator = $this->buildCreatorRef($definition->objectId);
+ if (!empty($creator)) {
+ $fields['creator'] = $creator;
+ }
+
+ // Add keywords from all associated taxonomy terms
+ $keywords = $this->buildKeywords($definition->objectId, $contentConfig);
+ if (!empty($keywords)) {
+ $fields['keywords'] = $keywords;
+ }
+
+ return $fields;
+ }
+
+ /**
+ * Derive values from taxonomy terms associated with this post.
+ *
+ * Looks for taxonomies whose slug contains $hint (e.g., 'style', 'colour').
+ */
+ private function deriveFromTaxonomy(int $postId, array $contentConfig, string $hint): array
+ {
+ $values = [];
+ $taxonomies = $contentConfig['taxonomies'] ?? [];
+
+ foreach ($taxonomies as $taxSlug) {
+ $slug = is_array($taxSlug) ? ($taxSlug['taxonomy'] ?? '') : $taxSlug;
+
+ if (!str_contains($slug, $hint)) {
+ continue;
+ }
+
+ $fullTax = str_starts_with($slug, BASE) ? $slug : BASE . $slug;
+ $terms = wp_get_post_terms($postId, $fullTax, ['fields' => 'names']);
+
+ if (!is_wp_error($terms) && !empty($terms)) {
+ $values = array_merge($values, $terms);
+ }
+ }
+
+ return array_unique($values);
+ }
+
+ /**
+ * Build Person reference from the post author.
+ */
+ private function buildCreatorRef(int $postId): ?array
+ {
+ $post = get_post($postId);
+
+ if (!$post || !$post->post_author) {
+ return null;
+ }
+
+ return SchemaReferenceBuilder::build(
+ $post->post_author,
+ 'user',
+ 'Person'
+ );
+ }
+
+ /**
+ * Collect all taxonomy term names as keywords.
+ */
+ private function buildKeywords(int $postId, array $contentConfig): array
+ {
+ $keywords = [];
+ $taxonomies = $contentConfig['taxonomies'] ?? [];
+
+ foreach ($taxonomies as $taxSlug) {
+ $slug = is_array($taxSlug) ? ($taxSlug['taxonomy'] ?? '') : $taxSlug;
+ $fullTax = str_starts_with($slug, BASE) ? $slug : BASE . $slug;
+
+ $terms = wp_get_post_terms($postId, $fullTax, ['fields' => 'names']);
+
+ if (!is_wp_error($terms)) {
+ $keywords = array_merge($keywords, $terms);
+ }
+ }
+
+ return array_unique($keywords);
+ }
+
+ /**
+ * Get content config from JVB_CONTENT constant.
+ */
+ private function getContentConfig(?string $contentType): array
+ {
+ if (!$contentType || !defined('JVB_CONTENT')) {
+ return [];
+ }
+
+ $slug = jvbNoBase($contentType);
+
+ return JVB_CONTENT[$slug] ?? [];
+ }
+}
diff --git a/inc/meta/Form.php b/inc/meta/Form.php
index f054157..83c3f8c 100644
--- a/inc/meta/Form.php
+++ b/inc/meta/Form.php
@@ -1654,12 +1654,13 @@
foreach ($fields as $fieldName => $fieldConfig) {
$fieldValue = $values[$fieldName] ?? '';
- $fullName = "{$name}:{$fieldName}";
+ $fullName = array_key_exists('wrap', $config) ? $fieldName : "{$name}:{$fieldName}";
$output .= static::render($fullName, $fieldValue, $fieldConfig);
}
$output .= sprintf('</%s>', esc_attr($wrapper));
+ unset($config['label']);
return static::fieldWrap($name, $output, $config);
}
--
Gitblit v1.10.0