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