=Start of SEO Schema refactor, fixing Form.php upload and group fields
3 files modified
9 files added
| | |
| | | |
| | | use JVBase\managers\Cache; |
| | | use JVBase\meta\Meta; |
| | | use JVBase\managers\SEO\schemas\SchemaDefinition; |
| | | use WP_Term; |
| | | use WP_User; |
| | | |
| | |
| | | $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, |
| | |
| | | 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; |
| | | } |
| | | |
| | | /** |
| | |
| | | } |
| | | |
| | | /** |
| | | * 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); |
| | | } |
| | | |
| | | /** |
| | |
| | | <?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'); |
| New file |
| | |
| | | <?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; |
| | | } |
| | | } |
| New file |
| | |
| | | <?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; |
| | | } |
| New file |
| | |
| | | <?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 |
| | | } |
| | | } |
| New file |
| | |
| | | <?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'); |
| New file |
| | |
| | | <?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 === []; |
| | | } |
| | | } |
| New file |
| | |
| | | <?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 |
| | | ); |
| | | } |
| | | } |
| New file |
| | |
| | | <?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] ?? []; |
| | | } |
| | | } |
| New file |
| | |
| | | <?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; |
| | | } |
| | | } |
| New file |
| | |
| | | <?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] ?? []; |
| | | } |
| | | } |
| | |
| | | |
| | | 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); |
| | | } |
| | | |