Jake Vanderwerf
2026-03-08 c19264ac916707096fe294d996a1b7fb85206b34
inc/managers/SEO/SchemaOutputManager.php
@@ -1,8 +1,11 @@
<?php
namespace JVBase\managers\SEO;
use JVBase\managers\CacheManager;
use JVBase\meta\MetaManager;
use JVBase\managers\Cache;
use JVBase\managers\SEO\schemas\SchemaResolverRegistry;
use JVBase\meta\Meta;
use JVBase\managers\SEO\schemas\SchemaDefinition;
use JVBase\registrar\Registrar;
use WP_Term;
use WP_User;
@@ -16,14 +19,14 @@
 * Integrates with The SEO Framework, letting it handle defaults
 * while we override with our configured templates.
 *
 * Now with integrated caching via CacheManager for performance.
 * Now with integrated caching via Cache for performance.
 */
class SchemaOutputManager
{
   private ConfigManager $config;
   private SchemaBuilder $registry;
   private ?TemplateResolver $resolver = null;
   private CacheManager $cache;
   private Cache $cache;
   private array $pseudoTypes = [
      'BeforeAfter',
   ];
@@ -31,12 +34,10 @@
   public function __construct()
   {
      $this->registry = SchemaBuilder::getInstance();
      $this->cache = CacheManager::for('schema');
      // Register cache connections
      $this->cache->connectTo('post', 'id');
      $this->cache->connectTo('taxonomy', 'id');
      $this->cache->connectTo('user', 'id');
      $this->cache = Cache::for('schema')
         ->connect('post',true)
         ->connect('taxonomy',true)
         ->connect('user',true);
      // Hook into TSF for meta
      add_filter('the_seo_framework_title_from_generation', [$this, 'filterTitle'], 10, 2);
@@ -50,6 +51,64 @@
      // Output our schema
      add_action('wp_head', [$this, 'outputSchema'], 1);
      add_filter('the_seo_framework_sitemap_exclude_ids', [$this, 'excludeHiddenSingles'], 10, 1);
   }
   /**
    * Exclude posts from sitemap based on hide_single and is_timeline flags
    *
    * @param array $ids Array of post IDs to exclude
    * @return array Modified array with hidden posts added
    */
   public function excludeHiddenSingles(array $ids): array
   {
      $hiddenTypes = array_map(function($type) {
            return jvbCheckBase($type);
         },
         Registrar::getFeatured('hide_single', 'post')
      );
      $timelineTypes = array_map(function($type) {
         return jvbCheckBase($type);
      }, Registrar::getFeatured('is_timeline', 'post'));
      $hiddenIds = [];
      // Get all posts from hide_single types
      if (!empty($hiddenTypes)) {
         $hiddenIds = $this->cache->remember(
            'hidden_single_posts',
            function() use ($hiddenTypes) {
               return get_posts([
                  'post_type' => $hiddenTypes,
                  'posts_per_page' => -1,
                  'fields' => 'ids',
                  'post_status' => 'publish',
               ]);
            }
         );
      }
      // Get child posts from timeline types
      if (!empty($timelineTypes)) {
         $timelineChildIds = $this->cache->remember(
            'timeline_child_posts',
            function() use ($timelineTypes) {
               return get_posts([
                  'post_type' => $timelineTypes,
                  'posts_per_page' => -1,
                  'fields' => 'ids',
                  'post_status' => 'publish',
                  'post_parent__not_in' => [0], // Only get posts with a parent
               ]);
            }
         );
         $hiddenIds = array_merge($hiddenIds, $timelineChildIds);
      }
      return array_merge($ids, $hiddenIds);
   }
   /**
@@ -58,7 +117,6 @@
   public function filterTitle(string $title, ?array $args): string
   {
      if ($args !== null) {
         // Not in the loop (admin, etc.)
         return $title;
      }
@@ -69,14 +127,22 @@
      $metaConfig = $this->config->meta();
      if (empty($metaConfig['title'])) {
      if (empty($metaConfig['metaTitle'])) {
         return $title;
      }
      $resolver = $this->getResolver();
      $customTitle = $resolver->resolve($metaConfig['title']);
      $customTitle = $resolver->resolve($metaConfig['metaTitle']);
      return $customTitle ?: $title;
      if (!$customTitle) {
         return $title;
      }
      // Strip trailing site name — TSF adds its own branding
      $siteName = get_bloginfo('name');
      $customTitle = preg_replace('/\s*[|\-–—]\s*' . preg_quote($siteName, '/') . '\s*$/i', '', $customTitle);
      return $customTitle;
   }
   /**
@@ -95,12 +161,12 @@
      $metaConfig = $this->config->meta();
      if (empty($metaConfig['description'])) {
      if (empty($metaConfig['metaDescription'])) {
         return $description;
      }
      $resolver = $this->getResolver();
      $customDescription = $resolver->resolve($metaConfig['description']);
      $customDescription = $resolver->resolve($metaConfig['metaDescription']);
      // Truncate to reasonable length
      if (strlen($customDescription) > 160) {
@@ -127,16 +193,16 @@
      $metaConfig = $this->config->meta();
      // Check for custom image
      if (!empty($metaConfig['image'])) {
      if (!empty($metaConfig['socialPreviewImage'])) {
         $resolver = $this->getResolver();
         $imageUrl = $resolver->resolve($metaConfig['image']);
         $imageUrl = $resolver->resolve($metaConfig['socialPreviewImage']);
         if ($imageUrl) {
            $params['og:image'] = $imageUrl;
            // Use twitter-specific image if set, otherwise use main image
            if (!empty($metaConfig['twitter_image'])) {
               $twitterImage = $resolver->resolve($metaConfig['twitter_image']);
            if (!empty($metaConfig['twitterImage'])) {
               $twitterImage = $resolver->resolve($metaConfig['twitterImage']);
               $params['twitter:image'] = $twitterImage ?: $imageUrl;
            } else {
               $params['twitter:image'] = $imageUrl;
@@ -323,10 +389,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,
@@ -344,96 +410,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;
   }
   /**
@@ -459,86 +459,26 @@
   }
   /**
    * Enhanced buildSchemaFromConfig with MetaManager 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 MetaManager if we have a context
      $meta = null;
      $context = $this->getCurrentContext();
      if ($context) {
         $meta = new MetaManager($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);
   }
   /**
@@ -596,12 +536,12 @@
         $resolver = $this->getResolver();
         $metaConfig = $this->config->meta();
         if (!empty($metaConfig['title'])) {
            $webpage['name'] = $resolver->resolve($metaConfig['title']);
         if (!empty($metaConfig['metaTitle'])) {
            $webpage['name'] = $resolver->resolve($metaConfig['metaTitle']);
         }
         if (!empty($metaConfig['description'])) {
            $webpage['description'] = $resolver->resolve($metaConfig['description']);
         if (!empty($metaConfig['metaDescription'])) {
            $webpage['description'] = $resolver->resolve($metaConfig['metaDescription']);
         }
      }
@@ -626,7 +566,7 @@
         $post = get_post();
         if ($post) {
            $postType = jvbNoBase($post->post_type);
            if (defined('JVB_CONTENT') && isset(JVB_CONTENT[$postType])) {
            if (Registrar::getInstance($postType)) {
               $this->config = ConfigManager::for($postType);
               return [
                  'objectType' => 'post',
@@ -639,7 +579,7 @@
         $term = get_queried_object();
         if ($term instanceof WP_Term) {
            $taxonomy = jvbNoBase($term->taxonomy);
            if (defined('JVB_TAXONOMY') && isset(JVB_TAXONOMY[$taxonomy])) {
            if (Registrar::getInstance($taxonomy)) {
               $this->config = ConfigManager::for($taxonomy);
               return [
                  'objectType' => 'term',
@@ -652,7 +592,7 @@
         $user = get_queried_object();
         if ($user instanceof WP_User) {
            $role = jvbUserRole($user->ID);
            if (defined('JVB_USER') && isset(JVB_USER[$role])) {
            if (Registrar::getInstance($role)) {
               $this->config = ConfigManager::for($role);
               return [
                  'objectType' => 'user',
@@ -668,7 +608,7 @@
         }
         $postType = jvbNoBase($postType);
         if (defined('JVB_CONTENT') && isset(JVB_CONTENT[$postType])) {
         if (Registrar::getInstance($postType)) {
            $this->config = ConfigManager::for($postType);
            return [
               'objectType' => 'archive',