Jake Vanderwerf
2026-02-09 2d0b98416804d8a132895720c9c33e6061bd6752
=Start of SEO Schema refactor, fixing Form.php upload and group fields
9 files added
3 files modified
1032 ■■■■ changed files
inc/managers/SEO/SchemaOutputManager.php 169 ●●●● patch | view | raw | blame | history
inc/managers/SEO/_setup.php 2 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/schemas/SchemaDefinition.php 59 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/schemas/SchemaResolverInterface.php 39 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/schemas/SchemaResolverRegistry.php 60 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/schemas/_setup.php 26 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/schemas/resolvers/BaseResolver.php 228 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/schemas/resolvers/CollectionPageResolver.php 88 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/schemas/resolvers/LocalBusinessResolver.php 88 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/schemas/resolvers/PersonResolver.php 124 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/schemas/resolvers/VisualArtworkResolver.php 146 ●●●●● patch | view | raw | blame | history
inc/meta/Form.php 3 ●●●● patch | view | raw | blame | history
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);
    }
    /**
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');
inc/managers/SEO/schemas/SchemaDefinition.php
New file
@@ -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;
    }
}
inc/managers/SEO/schemas/SchemaResolverInterface.php
New file
@@ -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;
}
inc/managers/SEO/schemas/SchemaResolverRegistry.php
New file
@@ -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
    }
}
inc/managers/SEO/schemas/_setup.php
New file
@@ -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');
inc/managers/SEO/schemas/resolvers/BaseResolver.php
New file
@@ -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 === [];
    }
}
inc/managers/SEO/schemas/resolvers/CollectionPageResolver.php
New file
@@ -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
        );
    }
}
inc/managers/SEO/schemas/resolvers/LocalBusinessResolver.php
New file
@@ -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] ?? [];
    }
}
inc/managers/SEO/schemas/resolvers/PersonResolver.php
New file
@@ -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;
    }
}
inc/managers/SEO/schemas/resolvers/VisualArtworkResolver.php
New file
@@ -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] ?? [];
    }
}
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);
    }