Jake Vanderwerf
2026-01-01 3acb42faee66868a76e653a34ef35de13ddf734f
inc/utility/Validator.php
@@ -12,6 +12,24 @@
{
   private array $errors = [];
   private array $warnings = [];
   protected array $validSchemaTypes = [
      'content' => [
         'Article', 'NewsArticle', 'BlogPosting', 'VisualArtwork',
         'Product', 'Service', 'Event', 'Person', 'CreativeWork',
         'MedicalProcedure', 'HowTo', 'Recipe', 'Review',
      ],
      'taxonomy' => [
         'CollectionPage', 'DefinedTerm', 'ItemList',
      ],
      'user' => [
         'Person',
      ],
   ];
   protected array $validModifiers = [
      'first', 'last', 'join', 'truncate', 'strip', 'lower', 'upper',
      'title', 'count', 'get', 'default', 'date', 'image_url', 'excerpt', 'plural'
   ];
   public function validateAll():array
   {
@@ -20,6 +38,8 @@
      $success['terms'] = $this->validateTaxonomyConfig(JVB_TAXONOMY);
      $success['user'] = $this->validateUserConfig(JVB_USER);
      $success['crossReference'] = $this->validateCrossReferences(JVB_CONTENT, JVB_TAXONOMY, JVB_USER);
      $success['seo'] = $this->validateSEOConfig();
      $success['schema'] = $this->validateSchemaConfig(JVB_SCHEMA ?? []);
      return $success;
   }
   /**
@@ -322,7 +342,7 @@
         $validTypes = [
            'text', 'textarea', 'number', 'email', 'url', 'select',
            'radio', 'checkbox', 'true_false', 'date', 'time',
            'datetime', 'color', 'image', 'file', 'gallery',
            'datetime', 'color', 'upload', 'image', 'file', 'gallery',
            'repeater', 'location', 'user', 'taxonomy', 'set'
         ];
@@ -365,10 +385,10 @@
            break;
         case 'repeater':
            if (empty($config['sub_fields'])) {
            if (empty($config['fields'])) {
               $this->addError(
                  "{$path}.fields.{$fieldName}",
                  "Repeater field requires 'sub_fields' array"
                  "Repeater field requires 'fields' definition array"
               );
            }
            break;
@@ -499,4 +519,225 @@
         }
      }
   }
   /**
    * Validate SEO configurations across all types
    */
   public function validateSEOConfig(): bool
   {
      $this->errors = [];
      $this->warnings = [];
      foreach (JVB_CONTENT ?? [] as $slug => $config) {
         if (isset($config['seo'])) {
            $this->validateTypeSEOConfig($slug, $config['seo'], 'content', $config);
         }
      }
      foreach (JVB_TAXONOMY ?? [] as $slug => $config) {
         if (isset($config['seo'])) {
            $this->validateTypeSEOConfig($slug, $config['seo'], 'taxonomy', $config);
         }
      }
      foreach (JVB_USER ?? [] as $slug => $config) {
         if (isset($config['seo'])) {
            $this->validateTypeSEOConfig($slug, $config['seo'], 'user', $config);
         }
      }
      $this->logResults();
      return empty($this->errors);
   }
   /**
    * Validate SEO config for a specific type
    */
   private function validateTypeSEOConfig(string $slug, array $seo, string $objectType, array $fullConfig): void
   {
      $path = "{$objectType}.{$slug}.seo";
      $availableFields = $this->getAvailableSEOFields($slug, $objectType, $fullConfig);
      if (isset($seo['schema_type'])) {
         $validTypes = $this->validSchemaTypes[$objectType] ?? $this->validSchemaTypes['content'];
         if (!in_array($seo['schema_type'], $validTypes)) {
            $this->addWarning("{$path}.schema_type", "'{$seo['schema_type']}' may not be valid. Common types: " . implode(', ', array_slice($validTypes, 0, 5)));
         }
      }
      if (isset($seo['field_map'])) {
         foreach ($seo['field_map'] as $prop => $source) {
            $this->validateFieldSource($source, $availableFields, "{$path}.field_map.{$prop}");
         }
      }
      if (isset($seo['meta']['title'])) {
         $this->validatePatternString($seo['meta']['title'], $availableFields, "{$path}.meta.title");
      }
      if (isset($seo['meta']['description'])) {
         $this->validatePatternString($seo['meta']['description'], $availableFields, "{$path}.meta.description");
      }
   }
   /**
    * Validate a field source reference
    */
   private function validateFieldSource(string $source, array $availableFields, string $path): void
   {
      if (empty($source)) {
         return;
      }
      if (str_contains($source, '{{')) {
         $this->validatePatternString($source, $availableFields, $path);
         return;
      }
      $field = explode('|', $source)[0];
      $field = explode('.', $field)[0];
      if (!in_array($field, $availableFields) && !in_array($field, ['site', 'author', 'meta', 'terms'])) {
         $this->addWarning($path, "Field '{$field}' may not exist");
      }
   }
   /**
    * Validate pattern string syntax
    */
   private function validatePatternString(string $pattern, array $availableFields, string $path): void
   {
      preg_match_all('/\{\{([^}]+)\}\}/', $pattern, $matches);
      foreach ($matches[1] as $token) {
         $token = trim($token);
         if (empty($token)) {
            $this->addError($path, "Empty placeholder {{}} found");
            continue;
         }
         $parts = explode('|', $token);
         $field = trim(explode('.', $parts[0])[0]);
         if (!in_array($field, $availableFields) && !in_array($field, ['site', 'author', 'meta', 'terms'])) {
            $this->addWarning($path, "Field '{$field}' in pattern may not exist");
         }
         if (isset($parts[1])) {
            $modifier = trim(explode(':', $parts[1])[0]);
            if (!in_array($modifier, $this->validModifiers)) {
               $this->addWarning($path, "Unknown modifier '|{$modifier}'");
            }
         }
      }
   }
   /**
    * Validate JVB_SCHEMA configuration
    */
   public function validateSchemaConfig(array $schema): bool
   {
      $this->errors = [];
      $this->warnings = [];
      if (isset($schema['business'])) {
         $this->validateBusinessSchema($schema['business']);
      }
      if (isset($schema['faqs']['items'])) {
         foreach ($schema['faqs']['items'] as $i => $faq) {
            if (empty($faq['question'])) {
               $this->addError("schema.faqs.items[{$i}].question", "FAQ question required");
            }
            if (empty($faq['answer'])) {
               $this->addError("schema.faqs.items[{$i}].answer", "FAQ answer required");
            }
         }
      }
      $this->logResults();
      return empty($this->errors);
   }
   /**
    * Validate business schema
    */
   private function validateBusinessSchema(array $config): void
   {
      $path = 'schema.business';
      if (empty($config['name'])) {
         $this->addError("{$path}.name", "Business name required");
      }
      if (isset($config['url']) && !filter_var($config['url'], FILTER_VALIDATE_URL)) {
         $this->addError("{$path}.url", "Invalid URL");
      }
      if (isset($config['email']) && !filter_var($config['email'], FILTER_VALIDATE_EMAIL)) {
         $this->addError("{$path}.email", "Invalid email");
      }
      if (isset($config['geo'])) {
         $lat = $config['geo']['lat'] ?? null;
         $lng = $config['geo']['lng'] ?? null;
         if ($lat !== null && (!is_numeric($lat) || $lat < -90 || $lat > 90)) {
            $this->addError("{$path}.geo.lat", "Latitude must be -90 to 90");
         }
         if ($lng !== null && (!is_numeric($lng) || $lng < -180 || $lng > 180)) {
            $this->addError("{$path}.geo.lng", "Longitude must be -180 to 180");
         }
      }
      if (isset($config['opening_hours'])) {
         $days = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'];
         foreach ($config['opening_hours'] as $day => $data) {
            if (!in_array(strtolower($day), $days)) {
               $this->addWarning("{$path}.opening_hours.{$day}", "Invalid day");
            }
            if (is_array($data) && empty($data['closed'])) {
               if (isset($data['open']) && !preg_match('/^\d{2}:\d{2}$/', $data['open'])) {
                  $this->addWarning("{$path}.opening_hours.{$day}.open", "Use HH:MM format");
               }
            }
         }
      }
      if (isset($config['aggregate_rating'])) {
         $value = $config['aggregate_rating']['value'] ?? null;
         if ($value !== null && (!is_numeric($value) || $value < 0 || $value > 5)) {
            $this->addError("{$path}.aggregate_rating.value", "Rating must be 0-5");
         }
      }
      if (isset($config['same_as'])) {
         foreach ($config['same_as'] as $i => $link) {
            $url = is_array($link) ? ($link['url'] ?? '') : $link;
            if (!empty($url) && !filter_var($url, FILTER_VALIDATE_URL)) {
               $this->addError("{$path}.same_as[{$i}]", "Invalid URL: {$url}");
            }
         }
      }
   }
   /**
    * Get available fields for SEO validation
    */
   private function getAvailableSEOFields(string $slug, string $objectType, array $config): array
   {
      $fields = match($objectType) {
         'content' => ['post_title', 'post_excerpt', 'post_content', 'post_date', 'post_modified', 'post_thumbnail', 'permalink'],
         'taxonomy' => ['term_name', 'term_description', 'term_slug', 'permalink', 'count'],
         'user' => ['display_name', 'first_name', 'last_name', 'user_email', 'description', 'permalink'],
         default => []
      };
      if (!empty($config['fields'])) {
         $fields = array_merge($fields, array_keys($config['fields']));
      }
      return $fields;
   }
}