<?php
|
namespace JVBase\utility;
|
|
use JVBase\registrar\Registrar;
|
|
if (!defined('ABSPATH')) {
|
exit;
|
}
|
/**
|
* Validates configuration arrays for content types, taxonomies, and user roles
|
* Catches errors early in the registration process
|
*/
|
class Validator
|
{
|
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
|
{
|
$success = [];
|
$success['content'] = $this->validateContentConfig(JVB_CONTENT);
|
$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;
|
}
|
/**
|
* Validate JVB_CONTENT configuration
|
*/
|
public function validateContentConfig(array $config): bool
|
{
|
$this->errors = [];
|
$this->warnings = [];
|
|
foreach ($config as $slug => $settings) {
|
$this->validateSlug($slug, 'content');
|
$this->validateContentSettings($slug, $settings);
|
}
|
|
if (!empty($this->errors)) {
|
error_log('Validation result: '.print_r($this->errors, true));
|
}
|
if (!empty($this->warnings)) {
|
error_log('Warnings: '.print_r($this->warnings, true));
|
}
|
|
return empty($this->errors);
|
}
|
|
/**
|
* Validate JVB_TAXONOMY configuration
|
*/
|
public function validateTaxonomyConfig(array $config): bool
|
{
|
$this->errors = [];
|
$this->warnings = [];
|
|
foreach ($config as $slug => $settings) {
|
$this->validateSlug($slug, 'taxonomy');
|
$this->validateTaxonomySettings($slug, $settings);
|
}
|
|
if (!empty($this->errors)) {
|
error_log('Validation result: '.print_r($this->errors, true));
|
}
|
if (!empty($this->warnings)) {
|
error_log('Warnings: '.print_r($this->warnings, true));
|
}
|
return empty($this->errors);
|
}
|
|
/**
|
* Validate JVB_USER configuration
|
*/
|
public function validateUserConfig(array $config): bool
|
{
|
$this->errors = [];
|
$this->warnings = [];
|
|
foreach ($config as $slug => $settings) {
|
$this->validateSlug($slug, 'user_role');
|
$this->validateUserSettings($slug, $settings);
|
}
|
|
if (!empty($this->errors)) {
|
error_log('Validation result: '.print_r($this->errors, true));
|
}
|
if (!empty($this->warnings)) {
|
error_log('Warnings: '.print_r($this->warnings, true));
|
}
|
return empty($this->errors);
|
}
|
|
/**
|
* Validate cross-references between configs
|
*/
|
public function validateCrossReferences(array $content, array $taxonomy, array $user): bool
|
{
|
$this->errors = [];
|
$this->warnings = [];
|
|
// Check taxonomy -> content references
|
foreach ($taxonomy as $taxSlug => $taxConfig) {
|
foreach ($taxConfig['for_content'] ?? [] as $contentType) {
|
if (!isset($content[$contentType])) {
|
$this->addError(
|
"taxonomy.{$taxSlug}.for_content",
|
"References non-existent content type '{$contentType}'"
|
);
|
}
|
}
|
|
// Check is_owned_by references
|
foreach ($taxConfig['is_owned_by'] ?? [] as $role) {
|
if (!isset($user[$role])) {
|
$this->addError(
|
"taxonomy.{$taxSlug}.is_owned_by",
|
"References non-existent user role '{$role}'"
|
);
|
}
|
}
|
}
|
|
// Check user -> content references
|
foreach ($user as $userSlug => $userConfig) {
|
// Check profile reference
|
if (isset($userConfig['profile']) && !isset($content[$userConfig['profile']])) {
|
$this->addError(
|
"user.{$userSlug}.profile",
|
"References non-existent content type '{$userConfig['profile']}'"
|
);
|
}
|
|
// Check can_create references
|
$this->validateCreatableContent($userSlug, $userConfig['can_create'] ?? [], $content, $taxonomy);
|
}
|
|
// Check field section references
|
foreach ($content as $contentSlug => $contentConfig) {
|
$this->validateFieldSections($contentSlug, $contentConfig, 'content');
|
}
|
|
if (!empty($this->errors)) {
|
error_log('Validation result: '.print_r($this->errors, true));
|
}
|
if (!empty($this->warnings)) {
|
error_log('Warnings: '.print_r($this->warnings, true));
|
}
|
|
return empty($this->errors);
|
}
|
|
/**
|
* Validate slug format
|
*/
|
private function validateSlug(string $slug, string $type): void
|
{
|
// Check for valid characters
|
if (!preg_match('/^[a-z0-9_-]+$/', $slug)) {
|
$this->addError(
|
"{$type}.{$slug}",
|
"Slug must contain only lowercase letters, numbers, hyphens, and underscores"
|
);
|
}
|
|
// Check length
|
if (strlen($slug) > 20) {
|
$this->addWarning(
|
"{$type}.{$slug}",
|
"Slug is longer than 20 characters, which may cause issues"
|
);
|
}
|
|
// Check for reserved WordPress terms
|
$reserved = ['post', 'page', 'attachment', 'revision', 'nav_menu_item', 'author', 'category', 'tag'];
|
if (in_array($slug, $reserved)) {
|
$this->addError(
|
"{$type}.{$slug}",
|
"Slug '{$slug}' is a reserved WordPress term"
|
);
|
}
|
}
|
|
/**
|
* Validate content type settings
|
*/
|
private function validateContentSettings(string $slug, array $settings): void
|
{
|
// Required fields
|
if (empty($settings['singular'])) {
|
$this->addError("content.{$slug}", "Missing required 'singular' label");
|
}
|
|
if (empty($settings['plural'])) {
|
$this->addError("content.{$slug}", "Missing required 'plural' label");
|
}
|
|
// Validate boolean flags
|
$booleanFields = [
|
'hide_single', 'show_feed', 'show_directory', 'karma',
|
'favouritable', 'responses', 'is_calendar', 'single_image'
|
];
|
|
foreach ($booleanFields as $field) {
|
if (isset($settings[$field]) && !is_bool($settings[$field])) {
|
$this->addError(
|
"content.{$slug}.{$field}",
|
"Field '{$field}' must be a boolean value"
|
);
|
}
|
}
|
|
// Validate fields configuration
|
if (isset($settings['fields'])) {
|
$this->validateFieldsConfig($slug, $settings['fields'], 'content');
|
}
|
|
// Validate sections if fields exist
|
if (!empty($settings['fields']) && !empty($settings['sections'])) {
|
$this->validateSectionsConfig($slug, $settings['sections']);
|
}
|
|
// Check for conflicting settings
|
if (($settings['hide_single'] ?? false) && ($settings['show_directory'] ?? false)) {
|
$this->addWarning(
|
"content.{$slug}",
|
"Content type has both 'hide_single' and 'show_directory' enabled"
|
);
|
}
|
}
|
|
/**
|
* Validate taxonomy settings
|
*/
|
private function validateTaxonomySettings(string $slug, array $settings): void
|
{
|
// Required fields
|
if (empty($settings['singular'])) {
|
$this->addError("taxonomy.{$slug}", "Missing required 'singular' label");
|
}
|
|
if (empty($settings['plural'])) {
|
$this->addError("taxonomy.{$slug}", "Missing required 'plural' label");
|
}
|
|
// Validate for_content
|
if (empty($settings['for_content']) || !is_array($settings['for_content'])) {
|
$this->addError(
|
"taxonomy.{$slug}",
|
"Missing or invalid 'for_content' array"
|
);
|
}
|
|
// Validate content taxonomy specific settings
|
if ($settings['is_content'] ?? false) {
|
if (empty($settings['content_table'])) {
|
$this->addWarning(
|
"taxonomy.{$slug}",
|
"Content taxonomy missing 'content_table' configuration"
|
);
|
}
|
}
|
|
// Validate ownership settings
|
if ($settings['is_ownable'] ?? false) {
|
if (empty($settings['is_owned_by'])) {
|
$this->addError(
|
"taxonomy.{$slug}",
|
"Ownable taxonomy missing 'is_owned_by' configuration"
|
);
|
}
|
}
|
}
|
|
/**
|
* Validate user role settings
|
*/
|
private function validateUserSettings(string $slug, array $settings): void
|
{
|
// Validate dashboard access
|
if ($settings['has_dashboard'] ?? false) {
|
if (empty($settings['can_create']) && empty($settings['profile'])) {
|
$this->addWarning(
|
"user.{$slug}",
|
"User has dashboard access but no content creation or profile"
|
);
|
}
|
}
|
|
// Validate registration fields
|
if ($settings['can_register'] ?? false) {
|
if (empty($settings['register_fields'])) {
|
$this->addWarning(
|
"user.{$slug}",
|
"User can register but has no registration fields defined"
|
);
|
}
|
}
|
|
// Validate profile consistency
|
if (!empty($settings['profile']) && empty($settings['has_dashboard'])) {
|
$this->addWarning(
|
"user.{$slug}",
|
"User has profile type but no dashboard access"
|
);
|
}
|
}
|
|
/**
|
* Validate fields configuration
|
*/
|
private function validateFieldsConfig(string $slug, array $fields, string $type): void
|
{
|
foreach ($fields as $fieldName => $fieldConfig) {
|
// Check for required field properties
|
if (empty($fieldConfig['type'])) {
|
$this->addError(
|
"{$type}.{$slug}.fields.{$fieldName}",
|
"Field missing required 'type' property"
|
);
|
}
|
|
// Validate field type
|
$validTypes = [
|
'text', 'textarea', 'number', 'email', 'url', 'select',
|
'radio', 'checkbox', 'true_false', 'date', 'time',
|
'datetime', 'color', 'upload', 'image', 'file', 'gallery',
|
'repeater', 'location', 'user', 'taxonomy', 'set'
|
];
|
|
if (isset($fieldConfig['type']) && !in_array($fieldConfig['type'], $validTypes)) {
|
$this->addError(
|
"{$type}.{$slug}.fields.{$fieldName}",
|
"Invalid field type '{$fieldConfig['type']}'"
|
);
|
}
|
|
// Validate field-specific configurations
|
$this->validateFieldTypeConfig($fieldName, $fieldConfig, "{$type}.{$slug}");
|
}
|
}
|
|
/**
|
* Validate field type specific configuration
|
*/
|
private function validateFieldTypeConfig(string $fieldName, array $config, string $path): void
|
{
|
switch ($config['type'] ?? '') {
|
case 'select':
|
case 'radio':
|
case 'checkbox':
|
if (empty($config['options'])) {
|
$this->addError(
|
"{$path}.fields.{$fieldName}",
|
"Field type '{$config['type']}' requires 'options' array"
|
);
|
}
|
break;
|
|
case 'taxonomy':
|
if (empty($config['taxonomy'])) {
|
$this->addError(
|
"{$path}.fields.{$fieldName}",
|
"Taxonomy field requires 'taxonomy' property"
|
);
|
}
|
break;
|
|
case 'repeater':
|
if (empty($config['fields'])) {
|
$this->addError(
|
"{$path}.fields.{$fieldName}",
|
"Repeater field requires 'fields' definition array"
|
);
|
}
|
break;
|
|
case 'number':
|
if (isset($config['min']) && isset($config['max']) && $config['min'] > $config['max']) {
|
$this->addError(
|
"{$path}.fields.{$fieldName}",
|
"Number field 'min' value cannot be greater than 'max'"
|
);
|
}
|
break;
|
}
|
}
|
|
/**
|
* Validate sections configuration
|
*/
|
private function validateSectionsConfig(string $slug, array $sections): void
|
{
|
foreach ($sections as $sectionSlug => $sectionConfig) {
|
if (empty($sectionConfig['label'])) {
|
$this->addError(
|
"content.{$slug}.sections.{$sectionSlug}",
|
"Section missing required 'label'"
|
);
|
}
|
}
|
}
|
|
/**
|
* Validate field sections match defined sections
|
*/
|
private function validateFieldSections(string $slug, array $config, string $type): void
|
{
|
if (empty($config['fields']) || empty($config['sections'])) {
|
return;
|
}
|
|
$definedSections = array_keys($config['sections']);
|
|
foreach ($config['fields'] as $fieldName => $fieldConfig) {
|
if (isset($fieldConfig['section']) && !in_array($fieldConfig['section'], $definedSections)) {
|
$this->addError(
|
"{$type}.{$slug}.fields.{$fieldName}",
|
"Field references non-existent section '{$fieldConfig['section']}'"
|
);
|
}
|
}
|
}
|
|
/**
|
* Validate creatable content references
|
*/
|
private function validateCreatableContent(string $userSlug, array $canCreate, array $content, array $taxonomy): void
|
{
|
foreach ($canCreate as $item) {
|
if (is_array($item)) {
|
foreach ($item as $subType => $types) {
|
foreach ($types as $type) {
|
if (!isset($content[$type]) && !isset($taxonomy[$type])) {
|
$this->addError(
|
"user.{$userSlug}.can_create",
|
"References non-existent type '{$type}'"
|
);
|
}
|
}
|
}
|
} else {
|
if (!isset($content[$item]) && !isset($taxonomy[$item])) {
|
$this->addError(
|
"user.{$userSlug}.can_create",
|
"References non-existent type '{$item}'"
|
);
|
}
|
}
|
}
|
}
|
|
/**
|
* Add error message
|
*/
|
private function addError(string $path, string $message): void
|
{
|
$this->errors[] = "[{$path}] {$message}";
|
}
|
|
/**
|
* Add warning message
|
*/
|
private function addWarning(string $path, string $message): void
|
{
|
$this->warnings[] = "[{$path}] {$message}";
|
}
|
|
/**
|
* Get validation errors
|
*/
|
public function getErrors(): array
|
{
|
return $this->errors;
|
}
|
|
/**
|
* Get validation warnings
|
*/
|
public function getWarnings(): array
|
{
|
return $this->warnings;
|
}
|
|
/**
|
* Output validation results to error log
|
*/
|
public function logResults(): void
|
{
|
if (!empty($this->errors)) {
|
error_log('[ConfigValidator] Validation Errors:');
|
foreach ($this->errors as $error) {
|
error_log(" - {$error}");
|
}
|
}
|
|
if (!empty($this->warnings) && WP_DEBUG) {
|
error_log('[ConfigValidator] Validation Warnings:');
|
foreach ($this->warnings as $warning) {
|
error_log(" - {$warning}");
|
}
|
}
|
}
|
|
/**
|
* Validate SEO configurations across all types
|
*/
|
public function validateSEOConfig(): bool
|
{
|
$this->errors = [];
|
$this->warnings = [];
|
|
foreach (Registrar::getRegistered('post') as $slug => $config) {
|
if (isset($config['seo'])) {
|
$this->validateTypeSEOConfig($slug, $config['seo'], 'content', $config);
|
}
|
}
|
|
foreach (Registrar::getRegistered('term') as $slug => $config) {
|
if (isset($config['seo'])) {
|
$this->validateTypeSEOConfig($slug, $config['seo'], 'taxonomy', $config);
|
}
|
}
|
|
foreach (Registrar::getRegistered('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;
|
}
|
}
|