| 1 |
<?php |
| 2 |
/** |
| 3 |
* Schema Management System Class |
| 4 |
* |
| 5 |
* Streamlined schema markup generation and management system with structured |
| 6 |
* data creation, validation, and automated deployment. Implements 2025 Schema.org |
| 7 |
* standards with clean separation of concerns. |
| 8 |
* |
| 9 |
* @package ThinkRank |
| 10 |
* @subpackage SEO |
| 11 |
* @since 1.0.0 |
| 12 |
*/ |
| 13 |
|
| 14 |
declare(strict_types=1); |
| 15 |
|
| 16 |
namespace ThinkRank\SEO; |
| 17 |
|
| 18 |
use ThinkRank\Config\Schema_Settings_Config; |
| 19 |
|
| 20 |
// Prevent direct access |
| 21 |
if (!defined('ABSPATH')) { |
| 22 |
exit; |
| 23 |
} |
| 24 |
|
| 25 |
/** |
| 26 |
* Schema Management System Class |
| 27 |
* |
| 28 |
* Provides streamlined schema markup management with generation, validation, |
| 29 |
* rich snippets optimization, and automated deployment. Focuses on core |
| 30 |
* structured data creation with clean separation of concerns. |
| 31 |
* |
| 32 |
* @since 1.0.0 |
| 33 |
*/ |
| 34 |
class Schema_Management_System extends Abstract_SEO_Manager { |
| 35 |
|
| 36 |
/** |
| 37 |
* Schema.org types with 2025 specifications |
| 38 |
* |
| 39 |
* @since 1.0.0 |
| 40 |
* @var array |
| 41 |
*/ |
| 42 |
private array $schema_types = [ |
| 43 |
'Article' => [ |
| 44 |
'name' => 'Article', |
| 45 |
'description' => 'News articles, blog posts, and editorial content', |
| 46 |
'required_properties' => ['headline', 'author', 'datePublished'], |
| 47 |
'recommended_properties' => ['image', 'publisher', 'dateModified', 'mainEntityOfPage'], |
| 48 |
'rich_snippets' => ['article', 'news_article', 'blog_posting'], |
| 49 |
'context_types' => ['post', 'page'], |
| 50 |
'priority' => 'high' |
| 51 |
], |
| 52 |
'TechnicalArticle' => [ |
| 53 |
'name' => 'TechnicalArticle', |
| 54 |
'description' => 'Technical documentation and tutorials', |
| 55 |
'required_properties' => ['headline', 'author', 'datePublished'], |
| 56 |
'recommended_properties' => ['image', 'publisher', 'dateModified', 'mainEntityOfPage', 'dependencies', 'proficiencyLevel'], |
| 57 |
'rich_snippets' => ['article', 'technical_article'], |
| 58 |
'context_types' => ['post', 'page'], |
| 59 |
'priority' => 'high' |
| 60 |
], |
| 61 |
'NewsArticle' => [ |
| 62 |
'name' => 'NewsArticle', |
| 63 |
'description' => 'News articles and press releases', |
| 64 |
'required_properties' => ['headline', 'author', 'datePublished'], |
| 65 |
'recommended_properties' => ['image', 'publisher', 'dateModified', 'mainEntityOfPage', 'dateline'], |
| 66 |
'rich_snippets' => ['article', 'news_article'], |
| 67 |
'context_types' => ['post', 'page'], |
| 68 |
'priority' => 'high' |
| 69 |
], |
| 70 |
'ScholarlyArticle' => [ |
| 71 |
'name' => 'ScholarlyArticle', |
| 72 |
'description' => 'Academic and research articles', |
| 73 |
'required_properties' => ['headline', 'author', 'datePublished'], |
| 74 |
'recommended_properties' => ['image', 'publisher', 'dateModified', 'mainEntityOfPage', 'citation', 'abstract'], |
| 75 |
'rich_snippets' => ['article', 'scholarly_article'], |
| 76 |
'context_types' => ['post', 'page'], |
| 77 |
'priority' => 'high' |
| 78 |
], |
| 79 |
'Report' => [ |
| 80 |
'name' => 'Report', |
| 81 |
'description' => 'Reports and analytical content', |
| 82 |
'required_properties' => ['headline', 'author', 'datePublished'], |
| 83 |
'recommended_properties' => ['image', 'publisher', 'dateModified', 'mainEntityOfPage'], |
| 84 |
'rich_snippets' => ['article', 'report'], |
| 85 |
'context_types' => ['post', 'page'], |
| 86 |
'priority' => 'medium' |
| 87 |
], |
| 88 |
'Product' => [ |
| 89 |
'name' => 'Product', |
| 90 |
'description' => 'Products for e-commerce and retail', |
| 91 |
'required_properties' => ['name', 'description'], |
| 92 |
'recommended_properties' => ['image', 'offers', 'brand', 'sku', 'gtin', 'review', 'aggregateRating'], |
| 93 |
'rich_snippets' => ['product', 'offer', 'review'], |
| 94 |
'context_types' => ['product', 'page', 'post'], |
| 95 |
'priority' => 'critical' |
| 96 |
], |
| 97 |
'LocalBusiness' => [ |
| 98 |
'name' => 'LocalBusiness', |
| 99 |
'description' => 'Local businesses and service providers', |
| 100 |
'required_properties' => ['name', 'address'], |
| 101 |
'recommended_properties' => ['telephone', 'url', 'openingHours', 'geo', 'priceRange'], |
| 102 |
'rich_snippets' => ['local_business', 'organization'], |
| 103 |
'context_types' => ['site', 'page'], |
| 104 |
'priority' => 'high' |
| 105 |
], |
| 106 |
'Organization' => [ |
| 107 |
'name' => 'Organization', |
| 108 |
'description' => 'Companies, corporations, and institutions', |
| 109 |
'required_properties' => ['name', 'url'], |
| 110 |
'recommended_properties' => ['logo', 'contactPoint', 'sameAs', 'address'], |
| 111 |
'rich_snippets' => ['organization', 'corporation'], |
| 112 |
'context_types' => ['site'], |
| 113 |
'priority' => 'medium' |
| 114 |
], |
| 115 |
'WebSite' => [ |
| 116 |
'name' => 'WebSite', |
| 117 |
'description' => 'Website and web application information', |
| 118 |
'required_properties' => ['name', 'url'], |
| 119 |
'recommended_properties' => ['description', 'author', 'publisher', 'potentialAction'], |
| 120 |
'rich_snippets' => ['website', 'sitelinks_searchbox'], |
| 121 |
'context_types' => ['site'], |
| 122 |
'priority' => 'high' |
| 123 |
], |
| 124 |
'SoftwareApplication' => [ |
| 125 |
'name' => 'SoftwareApplication', |
| 126 |
'description' => 'Software applications and web apps', |
| 127 |
'required_properties' => ['name', 'applicationCategory'], |
| 128 |
'recommended_properties' => ['description', 'url', 'offers', 'creator', 'features', 'aggregateRating'], |
| 129 |
'rich_snippets' => ['software', 'app_rating', 'pricing'], |
| 130 |
'context_types' => ['post', 'page'], |
| 131 |
'priority' => 'high' |
| 132 |
], |
| 133 |
'Person' => [ |
| 134 |
'name' => 'Person', |
| 135 |
'description' => 'Individual people and authors', |
| 136 |
'required_properties' => ['name'], |
| 137 |
'recommended_properties' => ['url', 'image'], |
| 138 |
'optional_properties' => ['jobTitle', 'worksFor', 'sameAs', 'description'], |
| 139 |
'rich_snippets' => ['person', 'author'], |
| 140 |
'context_types' => ['post', 'page'], |
| 141 |
'priority' => 'medium' |
| 142 |
], |
| 143 |
|
| 144 |
'HowTo' => [ |
| 145 |
'name' => 'HowTo', |
| 146 |
'description' => 'Step-by-step instructions and tutorials', |
| 147 |
'required_properties' => ['name', 'step'], |
| 148 |
'recommended_properties' => ['image', 'totalTime', 'estimatedCost', 'tool', 'supply'], |
| 149 |
'rich_snippets' => ['how_to', 'recipe'], |
| 150 |
'context_types' => ['post', 'page'], |
| 151 |
'priority' => 'medium' |
| 152 |
], |
| 153 |
'Event' => [ |
| 154 |
'name' => 'Event', |
| 155 |
'description' => 'Events, conferences, and gatherings', |
| 156 |
'required_properties' => ['name', 'startDate', 'location'], |
| 157 |
'recommended_properties' => ['endDate', 'description', 'image', 'offers', 'performer'], |
| 158 |
'rich_snippets' => ['event', 'social_event'], |
| 159 |
'context_types' => ['post', 'page'], |
| 160 |
'priority' => 'medium' |
| 161 |
], |
| 162 |
'VideoObject' => [ |
| 163 |
'name' => 'VideoObject', |
| 164 |
'description' => 'Videos and embedded media content', |
| 165 |
'required_properties' => ['name', 'description', 'thumbnailUrl', 'uploadDate'], |
| 166 |
'recommended_properties' => ['contentUrl', 'embedUrl', 'duration'], |
| 167 |
'rich_snippets' => ['video', 'video_carousel'], |
| 168 |
'context_types' => ['post', 'page'], |
| 169 |
'priority' => 'medium' |
| 170 |
], |
| 171 |
// Offered by the metabox dropdown and registered in Schema_Factory, but |
| 172 |
// absent here — generate_schema_markup() keys off this array, so a |
| 173 |
// Review request was silently skipped (#462). |
| 174 |
'Review' => [ |
| 175 |
'name' => 'Review', |
| 176 |
'description' => 'Reviews and ratings of a product, service or place', |
| 177 |
'required_properties' => ['itemReviewed', 'reviewRating', 'author'], |
| 178 |
'recommended_properties' => ['reviewBody', 'datePublished', 'publisher'], |
| 179 |
'rich_snippets' => ['review', 'review_snippet'], |
| 180 |
'context_types' => ['post', 'page'], |
| 181 |
'priority' => 'medium' |
| 182 |
], |
| 183 |
'Recipe' => [ |
| 184 |
'name' => 'Recipe', |
| 185 |
'description' => 'Cooking recipes and food preparation', |
| 186 |
'required_properties' => ['name', 'image', 'author', 'datePublished', 'description', 'recipeIngredient', 'recipeInstructions'], |
| 187 |
'recommended_properties' => ['cookTime', 'prepTime', 'totalTime', 'recipeYield', 'nutrition'], |
| 188 |
'rich_snippets' => ['recipe', 'cooking'], |
| 189 |
'context_types' => ['post', 'page'], |
| 190 |
'priority' => 'medium' |
| 191 |
], |
| 192 |
'BlogPosting' => [ |
| 193 |
'name' => 'BlogPosting', |
| 194 |
'description' => 'Blog posts and personal articles', |
| 195 |
'required_properties' => ['headline', 'author', 'datePublished'], |
| 196 |
'recommended_properties' => ['image', 'publisher', 'dateModified', 'mainEntityOfPage', 'wordCount'], |
| 197 |
'rich_snippets' => ['article', 'blog_posting'], |
| 198 |
'context_types' => ['post'], |
| 199 |
'priority' => 'high' |
| 200 |
], |
| 201 |
'WebPage' => [ |
| 202 |
'name' => 'WebPage', |
| 203 |
'description' => 'Individual web pages', |
| 204 |
'required_properties' => ['name', 'url'], |
| 205 |
'recommended_properties' => ['description', 'author', 'datePublished', 'breadcrumb'], |
| 206 |
'rich_snippets' => ['webpage', 'breadcrumb'], |
| 207 |
'context_types' => ['page'], |
| 208 |
'priority' => 'medium' |
| 209 |
], |
| 210 |
'FAQPage' => [ |
| 211 |
'name' => 'FAQPage', |
| 212 |
'description' => 'Frequently Asked Questions pages', |
| 213 |
'required_properties' => ['mainEntity'], |
| 214 |
'recommended_properties' => ['about', 'author'], |
| 215 |
'rich_snippets' => ['faq', 'question'], |
| 216 |
'context_types' => ['page', 'post'], |
| 217 |
'priority' => 'high' |
| 218 |
], |
| 219 |
|
| 220 |
]; |
| 221 |
|
| 222 |
/** |
| 223 |
* Rich snippets configuration with Google guidelines |
| 224 |
* |
| 225 |
* @since 1.0.0 |
| 226 |
* @var array |
| 227 |
*/ |
| 228 |
private array $rich_snippets_config = [ |
| 229 |
'testing_tools' => [ |
| 230 |
'google_structured_data' => 'https://search.google.com/test/rich-results', |
| 231 |
'schema_markup_validator' => 'https://validator.schema.org/', |
| 232 |
'google_rich_results' => 'https://search.google.com/search-console/rich-results' |
| 233 |
], |
| 234 |
'appearance_tracking' => [ |
| 235 |
'search_appearance' => ['title', 'description', 'image', 'rating', 'price'], |
| 236 |
'rich_features' => ['breadcrumbs', 'sitelinks', 'reviews', 'faq', 'how_to'], |
| 237 |
'performance_metrics' => ['click_through_rate', 'impressions', 'position'] |
| 238 |
], |
| 239 |
'optimization_guidelines' => [ |
| 240 |
'image_requirements' => [ |
| 241 |
'min_width' => 1200, |
| 242 |
'min_height' => 675, |
| 243 |
'aspect_ratio' => '16:9', |
| 244 |
'formats' => ['jpg', 'png', 'webp'] |
| 245 |
], |
| 246 |
'content_requirements' => [ |
| 247 |
'min_description_length' => 50, |
| 248 |
'max_description_length' => 300, |
| 249 |
'required_fields_completion' => 80 |
| 250 |
] |
| 251 |
] |
| 252 |
]; |
| 253 |
|
| 254 |
/** |
| 255 |
* Schema validation rules and requirements |
| 256 |
* |
| 257 |
* @since 1.0.0 |
| 258 |
* @var array |
| 259 |
*/ |
| 260 |
private array $validation_rules = [ |
| 261 |
'required_context' => '@context', |
| 262 |
'required_type' => '@type', |
| 263 |
'url_validation' => [ |
| 264 |
'protocols' => ['http', 'https'], |
| 265 |
'format_check' => true |
| 266 |
], |
| 267 |
'date_validation' => [ |
| 268 |
'format' => 'ISO8601', |
| 269 |
'timezone_aware' => true |
| 270 |
], |
| 271 |
'image_validation' => [ |
| 272 |
'url_required' => true, |
| 273 |
'dimensions_check' => true, |
| 274 |
'format_validation' => true |
| 275 |
], |
| 276 |
'text_validation' => [ |
| 277 |
'html_allowed' => false, |
| 278 |
'length_limits' => true, |
| 279 |
'encoding' => 'UTF-8' |
| 280 |
] |
| 281 |
]; |
| 282 |
|
| 283 |
/** |
| 284 |
* Schema deployment configuration |
| 285 |
* |
| 286 |
* @since 1.0.0 |
| 287 |
* @var array |
| 288 |
*/ |
| 289 |
private array $deployment_config = [ |
| 290 |
'output_methods' => [ |
| 291 |
'json_ld' => [ |
| 292 |
'enabled' => true, |
| 293 |
'priority' => 1, |
| 294 |
'location' => 'head' |
| 295 |
] |
| 296 |
], |
| 297 |
'caching' => [ |
| 298 |
'enabled' => true, |
| 299 |
'duration' => 3600, // 1 hour |
| 300 |
'invalidation_triggers' => ['content_update', 'settings_change'] |
| 301 |
], |
| 302 |
'conditional_loading' => [ |
| 303 |
'context_specific' => true, |
| 304 |
'user_agent_detection' => false, |
| 305 |
'performance_based' => true |
| 306 |
] |
| 307 |
]; |
| 308 |
|
| 309 |
/** |
| 310 |
* Schema Builder instance for schema construction |
| 311 |
* |
| 312 |
* @var \ThinkRank\SEO\Schema_Builder|null |
| 313 |
*/ |
| 314 |
private ?\ThinkRank\SEO\Schema_Builder $schema_builder = null; |
| 315 |
|
| 316 |
/** |
| 317 |
* Schema Cache Manager instance for performance optimization |
| 318 |
* |
| 319 |
* @since 1.0.0 |
| 320 |
* @var Schema_Cache_Manager|null |
| 321 |
*/ |
| 322 |
private ?Schema_Cache_Manager $cache_manager = null; |
| 323 |
|
| 324 |
/** |
| 325 |
* Whether the foreign-settings listener has been registered this request. |
| 326 |
* |
| 327 |
* Static because `thinkrank_seo_settings_saved` is a global hook — one |
| 328 |
* listener serves every instance. See the constructor for why (#463). |
| 329 |
* |
| 330 |
* @since 1.16.0 |
| 331 |
* @var bool |
| 332 |
*/ |
| 333 |
private static bool $foreign_settings_listener_registered = false; |
| 334 |
|
| 335 |
/** |
| 336 |
* Constructor |
| 337 |
* |
| 338 |
* @since 1.0.0 |
| 339 |
*/ |
| 340 |
public function __construct() { |
| 341 |
parent::__construct('schema_management_system'); |
| 342 |
|
| 343 |
// Load shared settings configuration |
| 344 |
if (!class_exists('ThinkRank\\Config\\Schema_Settings_Config')) { |
| 345 |
require_once THINKRANK_PLUGIN_DIR . 'includes/config/schema-settings-config.php'; |
| 346 |
} |
| 347 |
|
| 348 |
// Initialize Schema Builder for schema construction |
| 349 |
$this->initialize_schema_builder(); |
| 350 |
|
| 351 |
// Initialize Schema Cache Manager for performance optimization |
| 352 |
$this->initialize_cache_manager(); |
| 353 |
|
| 354 |
// LocalBusiness and Organization both read Business Info, which Site |
| 355 |
// Identity owns. Without this, editing an address or phone number never |
| 356 |
// refreshed the deployed schema (#455). |
| 357 |
// |
| 358 |
// Registered at most once per request. WordPress keys callbacks by |
| 359 |
// object hash, so binding $this here added a fresh listener for every |
| 360 |
// instance — and this class is constructed from inside the very callback |
| 361 |
// it registers, which doubled the listener count on every settings save |
| 362 |
// (#463). The guard is static because the hook itself is global. |
| 363 |
if (!self::$foreign_settings_listener_registered) { |
| 364 |
self::$foreign_settings_listener_registered = true; |
| 365 |
add_action('thinkrank_seo_settings_saved', [$this, 'refresh_schema_for_foreign_settings'], 10, 4); |
| 366 |
} |
| 367 |
} |
| 368 |
|
| 369 |
/** |
| 370 |
* Regenerate schema when another manager saves settings this schema reads. |
| 371 |
* |
| 372 |
* Site Identity owns the Business Info fields that feed LocalBusiness and |
| 373 |
* the Organization address/contactPoint, so a save there has to refresh the |
| 374 |
* deployed schema even though no schema setting changed. |
| 375 |
* |
| 376 |
* @since 2.0.2 |
| 377 |
* |
| 378 |
* @param string $manager_type Settings category that was saved. |
| 379 |
* @param array $settings Settings that were written. |
| 380 |
* @param string $context_type Context type. |
| 381 |
* @param int|null $context_id Context ID. |
| 382 |
* @return void |
| 383 |
*/ |
| 384 |
public function refresh_schema_for_foreign_settings( |
| 385 |
string $manager_type, |
| 386 |
array $settings, |
| 387 |
string $context_type, |
| 388 |
?int $context_id |
| 389 |
): void { |
| 390 |
if ('site_identity' !== $manager_type) { |
| 391 |
return; |
| 392 |
} |
| 393 |
|
| 394 |
$business_keys = [ |
| 395 |
'business_name', 'business_type', 'business_address', 'business_city', |
| 396 |
'business_state', 'business_postal_code', 'business_country', |
| 397 |
'business_phone', 'business_email', 'business_hours', |
| 398 |
'business_latitude', 'business_longitude', 'business_price_range', |
| 399 |
]; |
| 400 |
|
| 401 |
if (empty(array_intersect_key($settings, array_flip($business_keys)))) { |
| 402 |
return; |
| 403 |
} |
| 404 |
|
| 405 |
$schema_settings = $this->get_settings($context_type, $context_id); |
| 406 |
if (empty($schema_settings['auto_deploy'])) { |
| 407 |
return; |
| 408 |
} |
| 409 |
|
| 410 |
// Only refresh types that are actually deployed, so this never adds a |
| 411 |
// type the admin did not enable. |
| 412 |
$deployed = array_keys((array) $this->get_deployed_schemas($context_type, $context_id)); |
| 413 |
$affected = array_values(array_intersect($deployed, ['LocalBusiness', 'Organization'])); |
| 414 |
|
| 415 |
if (empty($affected)) { |
| 416 |
return; |
| 417 |
} |
| 418 |
|
| 419 |
try { |
| 420 |
$generation = $this->generate_schema_markup($context_type, $context_id, $affected); |
| 421 |
|
| 422 |
// Deploy the types that validated, not all-or-nothing. Gating on |
| 423 |
// deployment_ready meant one invalid type blocked every valid one |
| 424 |
// in the same batch (#470). |
| 425 |
$deployable = []; |
| 426 |
foreach ($affected as $type) { |
| 427 |
if (!empty($generation['generated_schemas'][$type]) |
| 428 |
&& !empty($generation['validation_results'][$type]['is_valid']) |
| 429 |
) { |
| 430 |
$deployable[$type] = $generation['generated_schemas'][$type]; |
| 431 |
} |
| 432 |
} |
| 433 |
|
| 434 |
if (!empty($deployable)) { |
| 435 |
$this->deploy_schema_markup($context_type, $context_id, $deployable); |
| 436 |
} |
| 437 |
} catch (\Exception $e) { |
| 438 |
if (defined('WP_DEBUG') && WP_DEBUG) { |
| 439 |
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log |
| 440 |
error_log('ThinkRank: Business Info schema refresh failed: ' . $e->getMessage()); |
| 441 |
} |
| 442 |
} |
| 443 |
} |
| 444 |
|
| 445 |
/** |
| 446 |
* Initialize Schema Builder |
| 447 |
* |
| 448 |
* @return void |
| 449 |
*/ |
| 450 |
private function initialize_schema_builder(): void { |
| 451 |
if (!class_exists('ThinkRank\\SEO\\Schema_Builder')) { |
| 452 |
require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-schema-builder.php'; |
| 453 |
} |
| 454 |
|
| 455 |
if (class_exists('ThinkRank\\SEO\\Schema_Builder')) { |
| 456 |
$this->schema_builder = new \ThinkRank\SEO\Schema_Builder(); |
| 457 |
} |
| 458 |
} |
| 459 |
|
| 460 |
/** |
| 461 |
* Initialize Schema Cache Manager |
| 462 |
* |
| 463 |
* @since 1.0.0 |
| 464 |
* |
| 465 |
* @return void |
| 466 |
*/ |
| 467 |
private function initialize_cache_manager(): void { |
| 468 |
if (!class_exists('ThinkRank\\SEO\\Schema_Cache_Manager')) { |
| 469 |
require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-schema-cache-manager.php'; |
| 470 |
} |
| 471 |
|
| 472 |
if (class_exists('ThinkRank\\SEO\\Schema_Cache_Manager')) { |
| 473 |
// Honour the stored cache_duration setting. It is exposed in |
| 474 |
// get_settings_schema() (min 300 / max 86400), validated, persisted |
| 475 |
// and surfaced through both abilities — but the cache manager was |
| 476 |
// always built from the hardcoded config value, so the setting had |
| 477 |
// no effect (#473). Falls back to the config default. |
| 478 |
$cache_duration = $this->deployment_config['caching']['duration'] ?? 3600; |
| 479 |
|
| 480 |
$stored = $this->get_settings('site', null)['cache_duration'] ?? null; |
| 481 |
if (is_numeric($stored) && (int) $stored > 0) { |
| 482 |
$cache_duration = (int) $stored; |
| 483 |
} |
| 484 |
|
| 485 |
$this->cache_manager = new Schema_Cache_Manager($cache_duration); |
| 486 |
} |
| 487 |
} |
| 488 |
|
| 489 |
/** |
| 490 |
* Generate schema markup with comprehensive content analysis integration |
| 491 |
* |
| 492 |
* @since 1.0.0 |
| 493 |
* |
| 494 |
* Generation is read-only by default. Persisting the result is opt-in via |
| 495 |
* `$options['persist']`, because this method is also reached from the |
| 496 |
* front-end read path (get_output_data()) and from GET routes — where a |
| 497 |
* DELETE + INSERT would destroy the admin's deployed rows and publish |
| 498 |
* types nobody deployed (#460). |
| 499 |
* |
| 500 |
* @param string $context_type Context type |
| 501 |
* @param int|null $context_id Context ID |
| 502 |
* @param array $schema_types Schema types to generate |
| 503 |
* @param array $options Generation options. Pass `persist => true` |
| 504 |
* from explicit write paths only. |
| 505 |
* @return array Comprehensive schema generation results |
| 506 |
*/ |
| 507 |
public function generate_schema_markup(string $context_type, ?int $context_id, array $schema_types = [], array $options = []): array { |
| 508 |
$generation = [ |
| 509 |
'context_type' => $context_type, |
| 510 |
'context_id' => $context_id, |
| 511 |
'generated_schemas' => [], |
| 512 |
'validation_results' => [], |
| 513 |
'rich_snippets_preview' => [], |
| 514 |
'optimization_recommendations' => [], |
| 515 |
'deployment_ready' => false, |
| 516 |
'generation_timestamp' => current_time('mysql') |
| 517 |
]; |
| 518 |
|
| 519 |
// Auto-detect schema types if not provided |
| 520 |
if (empty($schema_types)) { |
| 521 |
$schema_types = $this->auto_detect_schema_types($context_type, $context_id); |
| 522 |
} |
| 523 |
|
| 524 |
// Apply content schema settings from options |
| 525 |
$schema_types = $this->apply_content_schema_settings_from_options($schema_types, $options, $context_type); |
| 526 |
|
| 527 |
// Simplified: Content analysis and optimization moved to separate services |
| 528 |
// Schema generation focuses on core structured data creation |
| 529 |
|
| 530 |
// Generate schema for each type using Schema_Builder directly |
| 531 |
foreach ($schema_types as $schema_type) { |
| 532 |
if (isset($this->schema_types[$schema_type])) { |
| 533 |
// Prepare content data for Schema_Builder |
| 534 |
$content_data = $this->prepare_content_data_for_generator( |
| 535 |
$context_type, |
| 536 |
$context_id, |
| 537 |
[], // Simplified: content analysis handled by separate services |
| 538 |
[], // Simplified: optimization data handled by separate services |
| 539 |
$options |
| 540 |
); |
| 541 |
|
| 542 |
// Simplified: Knowledge graph enhancements moved to separate service |
| 543 |
|
| 544 |
// Generate schema using Schema_Builder directly |
| 545 |
$schema_data = $this->schema_builder->build_schema( |
| 546 |
$schema_type, |
| 547 |
$content_data, |
| 548 |
$context_type |
| 549 |
); |
| 550 |
|
| 551 |
// Apply rich snippets optimization if enabled |
| 552 |
if ($options['rich_snippets_optimization'] ?? true) { |
| 553 |
$schema_data = $this->apply_rich_snippets_optimization($schema_data, $schema_type); |
| 554 |
} |
| 555 |
|
| 556 |
$generation['generated_schemas'][$schema_type] = $schema_data; |
| 557 |
|
| 558 |
// Validate generated schema using proper Schema_Validator |
| 559 |
$validation = $this->validate_schema_markup($schema_data, $schema_type); |
| 560 |
$generation['validation_results'][$schema_type] = $validation; |
| 561 |
|
| 562 |
// Generate rich snippets preview |
| 563 |
$preview = $this->generate_rich_snippets_preview($schema_data, $schema_type); |
| 564 |
$generation['rich_snippets_preview'][$schema_type] = $preview; |
| 565 |
} |
| 566 |
} |
| 567 |
|
| 568 |
// Generate optimization recommendations |
| 569 |
$generation['optimization_recommendations'] = $this->generate_schema_optimization_recommendations( |
| 570 |
$generation['generated_schemas'], |
| 571 |
$generation['validation_results'] |
| 572 |
); |
| 573 |
|
| 574 |
// Check deployment readiness |
| 575 |
$generation['deployment_ready'] = $this->check_deployment_readiness($generation['validation_results']); |
| 576 |
|
| 577 |
// Persistence belongs to deployment, not generation. Every write path |
| 578 |
// (refresh_schema_for_foreign_settings(), auto_deploy_schema_on_settings_change(), |
| 579 |
// the deploy route) calls deploy_schema_markup() straight after generating, |
| 580 |
// so nothing needs to opt in today — the flag exists to keep this an |
| 581 |
// explicit decision rather than an accident. |
| 582 |
if (!empty($options['persist'])) { |
| 583 |
$this->store_schema_data($context_type, $context_id, $generation); |
| 584 |
} |
| 585 |
|
| 586 |
return $generation; |
| 587 |
} |
| 588 |
|
| 589 |
/** |
| 590 |
* Validate schema markup with comprehensive testing |
| 591 |
* |
| 592 |
* @since 1.0.0 |
| 593 |
* |
| 594 |
* @param array $schema_data Schema data to validate |
| 595 |
* @param string $schema_type Schema type |
| 596 |
* @param array $options Validation options |
| 597 |
* @return array Comprehensive validation results |
| 598 |
*/ |
| 599 |
public function validate_schema_markup(array $schema_data, string $schema_type, array $options = []): array { |
| 600 |
$validation = [ |
| 601 |
'schema_type' => $schema_type, |
| 602 |
'is_valid' => false, |
| 603 |
'validation_score' => 0, |
| 604 |
'required_properties_check' => [], |
| 605 |
'recommended_properties_check' => [], |
| 606 |
'structural_validation' => [], |
| 607 |
'google_guidelines_compliance' => [], |
| 608 |
'rich_snippets_eligibility' => [], |
| 609 |
'errors' => [], |
| 610 |
'warnings' => [], |
| 611 |
'suggestions' => [], |
| 612 |
'validation_timestamp' => current_time('mysql') |
| 613 |
]; |
| 614 |
|
| 615 |
// Use Schema_Validator for validation |
| 616 |
if (!class_exists('ThinkRank\\SEO\\Schema_Validator')) { |
| 617 |
require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-schema-validator.php'; |
| 618 |
} |
| 619 |
|
| 620 |
$schema_validator = new \ThinkRank\SEO\Schema_Validator(); |
| 621 |
$validator_validation = $schema_validator->validate_schema($schema_data); |
| 622 |
|
| 623 |
// Map Schema_Validator validation to our format |
| 624 |
if ($validator_validation) { |
| 625 |
$validation['is_valid'] = $validator_validation['valid'] ?? false; |
| 626 |
$validation['validation_score'] = $validator_validation['score'] ?? 0; |
| 627 |
$validation['errors'] = $validator_validation['errors'] ?? []; |
| 628 |
$validation['warnings'] = $validator_validation['warnings'] ?? []; |
| 629 |
$validation['suggestions'] = $validator_validation['suggestions'] ?? []; |
| 630 |
|
| 631 |
// Set basic validation checks |
| 632 |
$validation['required_properties_check'] = ['status' => 'checked']; |
| 633 |
$validation['recommended_properties_check'] = ['status' => 'checked']; |
| 634 |
$validation['structural_validation'] = ['valid_structure' => $validation['is_valid']]; |
| 635 |
$validation['google_guidelines_compliance'] = ['compliant' => $validation['is_valid']]; |
| 636 |
$validation['rich_snippets_eligibility'] = ['eligible' => $validation['is_valid']]; |
| 637 |
} else { |
| 638 |
// Fallback validation |
| 639 |
$validation['is_valid'] = !empty($schema_data['@type']); |
| 640 |
$validation['validation_score'] = $validation['is_valid'] ? 85 : 0; |
| 641 |
} |
| 642 |
|
| 643 |
return $validation; |
| 644 |
} |
| 645 |
|
| 646 |
/** |
| 647 |
* Optimize rich snippets with preview and testing capabilities |
| 648 |
* |
| 649 |
* @since 1.0.0 |
| 650 |
* |
| 651 |
* @param array $schema_data Schema data to optimize |
| 652 |
* @param string $schema_type Schema type |
| 653 |
* @param array $options Optimization options |
| 654 |
* @return array Rich snippets optimization results |
| 655 |
*/ |
| 656 |
public function optimize_rich_snippets(array $schema_data, string $schema_type, array $options = []): array { |
| 657 |
$optimization = [ |
| 658 |
'schema_type' => $schema_type, |
| 659 |
'original_schema' => $schema_data, |
| 660 |
'optimized_schema' => [], |
| 661 |
'rich_snippets_preview' => [], |
| 662 |
'optimization_score' => 0, |
| 663 |
'appearance_probability' => 0, |
| 664 |
'optimization_changes' => [], |
| 665 |
'testing_results' => [], |
| 666 |
'recommendations' => [], |
| 667 |
'optimization_timestamp' => current_time('mysql') |
| 668 |
]; |
| 669 |
|
| 670 |
// Rich snippets optimization is not yet fully implemented |
| 671 |
// Return the original schema with basic optimization info |
| 672 |
$optimization['optimized_schema'] = $schema_data; |
| 673 |
|
| 674 |
// Generate rich snippets preview |
| 675 |
$optimization['rich_snippets_preview'] = $this->generate_rich_snippets_preview($schema_data, $schema_type); |
| 676 |
|
| 677 |
// Basic optimization metrics |
| 678 |
$optimization['optimization_score'] = 85; // Default score |
| 679 |
$optimization['appearance_probability'] = 75; // Default probability |
| 680 |
$optimization['optimization_changes'] = []; |
| 681 |
$optimization['testing_results'] = ['status' => 'not_implemented']; |
| 682 |
$optimization['recommendations'] = ['message' => 'Rich snippets optimization is not yet fully implemented']; |
| 683 |
|
| 684 |
return $optimization; |
| 685 |
} |
| 686 |
|
| 687 |
/** |
| 688 |
* Deploy schema markup with automated implementation |
| 689 |
* |
| 690 |
* @since 1.0.0 |
| 691 |
* |
| 692 |
* @param string $context_type Context type |
| 693 |
* @param int|null $context_id Context ID |
| 694 |
* @param array $schema_data Schema data to deploy |
| 695 |
* @param array $options Deployment options |
| 696 |
* @return array Schema deployment results |
| 697 |
*/ |
| 698 |
public function deploy_schema_markup(string $context_type, ?int $context_id, array $schema_data, array $options = []): array { |
| 699 |
$deployment = [ |
| 700 |
'context_type' => $context_type, |
| 701 |
'context_id' => $context_id, |
| 702 |
'deployment_method' => 'json_ld', // Always JSON-LD (only supported method) |
| 703 |
'deployment_status' => 'pending', |
| 704 |
'deployed_schemas' => [], |
| 705 |
'deployment_location' => 'head', |
| 706 |
'cache_status' => [], |
| 707 |
'validation_post_deployment' => [], |
| 708 |
'deployment_timestamp' => current_time('mysql') |
| 709 |
]; |
| 710 |
|
| 711 |
// Determine deployment method |
| 712 |
$deployment['deployment_method'] = $this->determine_deployment_method($options); |
| 713 |
|
| 714 |
// When the caller owns the whole context — the user pressing Deploy, where |
| 715 |
// the payload is exactly what the preview showed — anything not in that |
| 716 |
// payload should come off the page (#464). Incremental callers such as |
| 717 |
// auto_deploy_schema_on_settings_change() pass only the types they |
| 718 |
// regenerated, so they must NOT retire the rest. |
| 719 |
if (!empty($options['authoritative'])) { |
| 720 |
$deployment['retired_schemas'] = $this->retire_schema_types( |
| 721 |
$context_type, |
| 722 |
$context_id, |
| 723 |
array_diff( |
| 724 |
array_keys($this->get_deployed_schemas($context_type, $context_id)), |
| 725 |
array_keys($schema_data) |
| 726 |
) |
| 727 |
); |
| 728 |
} |
| 729 |
|
| 730 |
// Deploy each schema |
| 731 |
foreach ($schema_data as $schema_type => $schema) { |
| 732 |
$deploy_result = $this->deploy_single_schema($schema, $schema_type, $deployment['deployment_method'], $context_type, $context_id); |
| 733 |
$deployment['deployed_schemas'][$schema_type] = $deploy_result; |
| 734 |
} |
| 735 |
|
| 736 |
// Clean up duplicate schemas |
| 737 |
$this->cleanup_duplicate_schemas($context_type, $context_id); |
| 738 |
|
| 739 |
// CACHE INVALIDATION: Clear cache after successful deployment |
| 740 |
$cache_invalidated = false; |
| 741 |
if ($this->cache_manager && !empty($deployment['deployed_schemas'])) { |
| 742 |
$this->cache_manager->invalidate_context_cache($context_type, $context_id); |
| 743 |
$cache_invalidated = true; |
| 744 |
} |
| 745 |
|
| 746 |
$deployment['cache_status'] = $cache_invalidated |
| 747 |
? ['cache_updated' => true, 'message' => 'Schema cache invalidated'] |
| 748 |
: ['cache_updated' => false, 'message' => 'No schema cache to invalidate']; |
| 749 |
|
| 750 |
// Post-deployment verification: read back through the same accessor the |
| 751 |
// front end uses, so a row that was written but is not retrievable (wrong |
| 752 |
// context, inactive, stale cache) is reported as a failure instead of |
| 753 |
// being assumed successful. |
| 754 |
$deployment['validation_post_deployment'] = $this->verify_deployment( |
| 755 |
$context_type, |
| 756 |
$context_id, |
| 757 |
array_keys($deployment['deployed_schemas']) |
| 758 |
); |
| 759 |
|
| 760 |
$writes_ok = !empty($deployment['deployed_schemas']); |
| 761 |
foreach ($deployment['deployed_schemas'] as $deploy_result) { |
| 762 |
if (empty($deploy_result['deployed'])) { |
| 763 |
$writes_ok = false; |
| 764 |
break; |
| 765 |
} |
| 766 |
} |
| 767 |
|
| 768 |
$deployment['deployment_status'] = |
| 769 |
($writes_ok && !empty($deployment['validation_post_deployment']['validation_passed'])) |
| 770 |
? 'success' |
| 771 |
: 'failed'; |
| 772 |
|
| 773 |
return $deployment; |
| 774 |
} |
| 775 |
|
| 776 |
/** |
| 777 |
* Verify deployed schema is retrievable after a deploy. |
| 778 |
* |
| 779 |
* Reads back through get_deployed_schemas() — the same accessor |
| 780 |
* Frontend\SEO_Manager::output_site_schema_markup() uses to emit schema — so |
| 781 |
* the check reflects what will actually reach the page rather than only that |
| 782 |
* an INSERT returned without error. |
| 783 |
* |
| 784 |
* @since 1.32.0 |
| 785 |
* |
| 786 |
* @param string $context_type Context type |
| 787 |
* @param int|null $context_id Context ID |
| 788 |
* @param array $expected_types Schema types that were just deployed |
| 789 |
* @return array Validation result |
| 790 |
*/ |
| 791 |
private function verify_deployment(string $context_type, ?int $context_id, array $expected_types): array { |
| 792 |
if (empty($expected_types)) { |
| 793 |
return [ |
| 794 |
'validation_passed' => false, |
| 795 |
'message' => 'No schema was deployed', |
| 796 |
'missing_types' => [] |
| 797 |
]; |
| 798 |
} |
| 799 |
|
| 800 |
$retrieved = $this->get_deployed_schemas($context_type, $context_id); |
| 801 |
$missing = array_values(array_diff($expected_types, array_keys($retrieved))); |
| 802 |
|
| 803 |
if (!empty($missing)) { |
| 804 |
return [ |
| 805 |
'validation_passed' => false, |
| 806 |
'message' => sprintf( |
| 807 |
/* translators: %s: comma-separated list of schema types */ |
| 808 |
__('Deployed schema could not be read back: %s', 'thinkrank'), |
| 809 |
implode(', ', $missing) |
| 810 |
), |
| 811 |
'missing_types' => $missing |
| 812 |
]; |
| 813 |
} |
| 814 |
|
| 815 |
return [ |
| 816 |
'validation_passed' => true, |
| 817 |
'message' => __('Schema deployed and read back from storage', 'thinkrank'), |
| 818 |
'missing_types' => [] |
| 819 |
]; |
| 820 |
} |
| 821 |
|
| 822 |
/** |
| 823 |
* Track schema performance and rich snippet appearances |
| 824 |
* |
| 825 |
* @since 1.0.0 |
| 826 |
* |
| 827 |
* @param string $context_type Context type |
| 828 |
* @param int|null $context_id Context ID |
| 829 |
* @param array $options Tracking options |
| 830 |
* @return array Schema performance tracking results |
| 831 |
*/ |
| 832 |
public function track_schema_performance(string $context_type, ?int $context_id, array $options = []): array { |
| 833 |
// Performance tracking is not yet implemented |
| 834 |
// This method returns empty data structure for API compatibility |
| 835 |
return [ |
| 836 |
'context_type' => $context_type, |
| 837 |
'context_id' => $context_id, |
| 838 |
'rich_snippets_appearances' => [], |
| 839 |
'search_performance' => [], |
| 840 |
'click_through_rates' => [], |
| 841 |
'schema_errors' => [], |
| 842 |
'performance_trends' => [], |
| 843 |
'optimization_impact' => [], |
| 844 |
'tracking_timestamp' => current_time('mysql'), |
| 845 |
'tracking_enabled' => false, |
| 846 |
'message' => 'Performance tracking feature is not yet implemented' |
| 847 |
]; |
| 848 |
} |
| 849 |
|
| 850 |
/** |
| 851 |
* Validate SEO settings (implements interface) |
| 852 |
* |
| 853 |
* @since 1.0.0 |
| 854 |
* |
| 855 |
* @param array $settings Settings array to validate |
| 856 |
* @return array Validation results |
| 857 |
*/ |
| 858 |
public function validate_settings(array $settings): array { |
| 859 |
$validation = [ |
| 860 |
'valid' => true, |
| 861 |
'errors' => [], |
| 862 |
'warnings' => [], |
| 863 |
'suggestions' => [], |
| 864 |
'score' => 100 |
| 865 |
]; |
| 866 |
|
| 867 |
// Validate schema types configuration |
| 868 |
if (isset($settings['enabled_schema_types']) && is_array($settings['enabled_schema_types'])) { |
| 869 |
foreach ($settings['enabled_schema_types'] as $schema_type) { |
| 870 |
if (!isset($this->schema_types[$schema_type])) { |
| 871 |
$validation['errors'][] = "Invalid schema type: {$schema_type}"; |
| 872 |
$validation['valid'] = false; |
| 873 |
} |
| 874 |
} |
| 875 |
} |
| 876 |
|
| 877 |
// Note: Only JSON-LD deployment method is supported (no validation needed since it's hardcoded) |
| 878 |
|
| 879 |
// Validate auto-generation settings |
| 880 |
if (isset($settings['auto_generate_schema']) && !is_bool($settings['auto_generate_schema'])) { |
| 881 |
$validation['errors'][] = 'Auto-generate schema setting must be boolean'; |
| 882 |
$validation['valid'] = false; |
| 883 |
} |
| 884 |
|
| 885 |
// Validate validation requirements |
| 886 |
if (isset($settings['validation_level'])) { |
| 887 |
$valid_levels = ['strict', 'moderate', 'lenient']; |
| 888 |
if (!in_array($settings['validation_level'], $valid_levels, true)) { |
| 889 |
$validation['errors'][] = 'Invalid validation level specified'; |
| 890 |
$validation['valid'] = false; |
| 891 |
} |
| 892 |
} |
| 893 |
|
| 894 |
// Validate rich snippets optimization |
| 895 |
if (isset($settings['rich_snippets_optimization']) && !is_bool($settings['rich_snippets_optimization'])) { |
| 896 |
$validation['errors'][] = 'Rich snippets optimization setting must be boolean'; |
| 897 |
$validation['valid'] = false; |
| 898 |
} |
| 899 |
|
| 900 |
// Validate performance tracking |
| 901 |
if (isset($settings['performance_tracking']) && !is_bool($settings['performance_tracking'])) { |
| 902 |
$validation['errors'][] = 'Performance tracking setting must be boolean'; |
| 903 |
$validation['valid'] = false; |
| 904 |
} |
| 905 |
|
| 906 |
// Validate cache settings |
| 907 |
if (isset($settings['cache_duration'])) { |
| 908 |
if (!is_numeric($settings['cache_duration']) || $settings['cache_duration'] < 0) { |
| 909 |
$validation['errors'][] = 'Cache duration must be a positive number'; |
| 910 |
$validation['valid'] = false; |
| 911 |
} |
| 912 |
} |
| 913 |
|
| 914 |
// Calculate validation score |
| 915 |
$validation['score'] = $this->calculate_validation_score($validation); |
| 916 |
|
| 917 |
return $validation; |
| 918 |
} |
| 919 |
|
| 920 |
/** |
| 921 |
* Get output data for frontend rendering (implements interface) |
| 922 |
* |
| 923 |
* @since 1.0.0 |
| 924 |
* |
| 925 |
* @param string $context_type The context type |
| 926 |
* @param int|null $context_id Optional. Context ID |
| 927 |
* @return array Output data ready for frontend rendering |
| 928 |
*/ |
| 929 |
public function get_output_data(string $context_type, ?int $context_id): array { |
| 930 |
$settings = $this->get_settings($context_type, $context_id); |
| 931 |
|
| 932 |
$output = [ |
| 933 |
'schema_dashboard' => [], |
| 934 |
'generated_schemas' => [], |
| 935 |
'validation_results' => [], |
| 936 |
'rich_snippets_preview' => [], |
| 937 |
'performance_data' => [], |
| 938 |
'recommendations' => [], |
| 939 |
// Report the real setting. Hardcoding true here told every consumer |
| 940 |
// the feature was on even when the master switch was off (#461). |
| 941 |
'enabled' => (bool) ($settings['enabled'] ?? true) |
| 942 |
]; |
| 943 |
|
| 944 |
// Get enabled schema types |
| 945 |
$enabled_types = $settings['enabled_schema_types'] ?? []; |
| 946 |
|
| 947 |
// Auto-generate schema types if enabled and no manual types specified |
| 948 |
if (empty($enabled_types) && ($settings['auto_generate_schema'] ?? true)) { |
| 949 |
$enabled_types = $this->auto_detect_schema_types($context_type, $context_id); |
| 950 |
} |
| 951 |
|
| 952 |
// Add content-specific schema types based on settings |
| 953 |
$enabled_types = $this->apply_content_schema_settings($enabled_types, $settings, $context_type); |
| 954 |
|
| 955 |
if (!empty($enabled_types)) { |
| 956 |
// Generate schema markup with enhanced options |
| 957 |
$generation_options = [ |
| 958 |
'knowledge_graph' => $settings['knowledge_graph'] ?? true, |
| 959 |
'rich_snippets_optimization' => $settings['rich_snippets_optimization'] ?? true, |
| 960 |
'validation_level' => $settings['validation_level'] ?? 'moderate' |
| 961 |
]; |
| 962 |
|
| 963 |
$generation_results = $this->generate_schema_markup($context_type, $context_id, $enabled_types, $generation_options); |
| 964 |
|
| 965 |
// Populate output data |
| 966 |
$output['generated_schemas'] = $generation_results['generated_schemas'] ?? []; |
| 967 |
$output['validation_results'] = $generation_results['validation_results'] ?? []; |
| 968 |
$output['rich_snippets_preview'] = $generation_results['rich_snippets_preview'] ?? []; |
| 969 |
$output['recommendations'] = $generation_results['optimization_recommendations'] ?? []; |
| 970 |
|
| 971 |
// Get schema dashboard data |
| 972 |
$output['schema_dashboard'] = [ |
| 973 |
'total_schemas' => count($generation_results['generated_schemas'] ?? []), |
| 974 |
'valid_schemas' => count(array_filter($generation_results['validation_results'] ?? [], function($v) { return $v['is_valid'] ?? false; })), |
| 975 |
'deployment_ready' => $generation_results['deployment_ready'] ?? false, |
| 976 |
'last_generated' => current_time('mysql') |
| 977 |
]; |
| 978 |
|
| 979 |
// Get performance data if tracking enabled |
| 980 |
if ($settings['performance_tracking'] ?? false) { |
| 981 |
$output['performance_data'] = $this->track_schema_performance($context_type, $context_id); |
| 982 |
} |
| 983 |
} |
| 984 |
|
| 985 |
return $output; |
| 986 |
} |
| 987 |
|
| 988 |
// Removed knowledge graph enhancements - moved to separate service |
| 989 |
// Social links and enhanced data handled by Schema_Builder directly |
| 990 |
|
| 991 |
/** |
| 992 |
* Apply rich snippets optimization to schema data |
| 993 |
* |
| 994 |
* @since 1.0.0 |
| 995 |
* |
| 996 |
* @param array $schema_data Schema data |
| 997 |
* @param string $schema_type Schema type |
| 998 |
* @return array Optimized schema data |
| 999 |
*/ |
| 1000 |
private function apply_rich_snippets_optimization(array $schema_data, string $schema_type): array { |
| 1001 |
switch ($schema_type) { |
| 1002 |
case 'Article': |
| 1003 |
case 'BlogPosting': |
| 1004 |
// Rich snippets optimization for articles |
| 1005 |
// Note: Image is optional - only include if user provides one |
| 1006 |
// No default image fallback to avoid non-existent image URLs |
| 1007 |
|
| 1008 |
// Optimize headline length for rich snippets |
| 1009 |
if (!empty($schema_data['headline']) && strlen($schema_data['headline']) > 110) { |
| 1010 |
$schema_data['headline'] = substr($schema_data['headline'], 0, 107) . '...'; |
| 1011 |
} |
| 1012 |
break; |
| 1013 |
|
| 1014 |
case 'Organization': |
| 1015 |
// Ensure logo for rich snippets |
| 1016 |
if (empty($schema_data['logo'])) { |
| 1017 |
$schema_data['logo'] = $this->get_default_organization_logo(); |
| 1018 |
} |
| 1019 |
break; |
| 1020 |
|
| 1021 |
case 'Product': |
| 1022 |
// Ensure required properties for product rich snippets |
| 1023 |
if (empty($schema_data['offers'])) { |
| 1024 |
$schema_data['offers'] = [ |
| 1025 |
'@type' => 'Offer', |
| 1026 |
'availability' => 'https://schema.org/InStock', |
| 1027 |
'priceCurrency' => 'USD' |
| 1028 |
]; |
| 1029 |
} |
| 1030 |
break; |
| 1031 |
} |
| 1032 |
|
| 1033 |
return $schema_data; |
| 1034 |
} |
| 1035 |
|
| 1036 |
/** |
| 1037 |
* Apply content schema settings from options to enabled types |
| 1038 |
* |
| 1039 |
* @since 1.0.0 |
| 1040 |
* |
| 1041 |
* @param array $enabled_types Current enabled types |
| 1042 |
* @param array $options Generation options |
| 1043 |
* @param string $context_type Context type |
| 1044 |
* @return array Enhanced enabled types |
| 1045 |
*/ |
| 1046 |
private function apply_content_schema_settings_from_options(array $enabled_types, array $options, string $context_type): array { |
| 1047 |
// For site context: only apply site-level schema settings |
| 1048 |
if ($context_type === 'site') { |
| 1049 |
// Add local business schema if enabled |
| 1050 |
if ($options['enable_local_business'] ?? false) { |
| 1051 |
if (!in_array('LocalBusiness', $enabled_types, true)) { |
| 1052 |
$enabled_types[] = 'LocalBusiness'; |
| 1053 |
} |
| 1054 |
} |
| 1055 |
|
| 1056 |
return $enabled_types; |
| 1057 |
} |
| 1058 |
|
| 1059 |
// For post/page context: apply all schema settings (metabox functionality) |
| 1060 |
|
| 1061 |
// Add article schema if enabled and context is appropriate |
| 1062 |
if ($options['enable_article_schema'] ?? false) { |
| 1063 |
if (in_array($context_type, ['post', 'page'], true) && !in_array('Article', $enabled_types, true)) { |
| 1064 |
$enabled_types[] = 'Article'; |
| 1065 |
} |
| 1066 |
} |
| 1067 |
|
| 1068 |
// Add FAQ schema if enabled |
| 1069 |
if ($options['enable_faq_schema'] ?? false) { |
| 1070 |
if (!in_array('FAQPage', $enabled_types, true)) { |
| 1071 |
$enabled_types[] = 'FAQPage'; |
| 1072 |
} |
| 1073 |
} |
| 1074 |
|
| 1075 |
// Add How-To schema if enabled |
| 1076 |
if ($options['enable_howto_schema'] ?? false) { |
| 1077 |
if (!in_array('HowTo', $enabled_types, true)) { |
| 1078 |
$enabled_types[] = 'HowTo'; |
| 1079 |
} |
| 1080 |
} |
| 1081 |
|
| 1082 |
// Add product schema if enabled and context is appropriate |
| 1083 |
if ($options['enable_product_schema'] ?? false) { |
| 1084 |
if ($context_type === 'product' && !in_array('Product', $enabled_types, true)) { |
| 1085 |
$enabled_types[] = 'Product'; |
| 1086 |
} |
| 1087 |
} |
| 1088 |
|
| 1089 |
// Add local business schema if enabled |
| 1090 |
if ($options['enable_local_business'] ?? false) { |
| 1091 |
if (!in_array('LocalBusiness', $enabled_types, true)) { |
| 1092 |
$enabled_types[] = 'LocalBusiness'; |
| 1093 |
} |
| 1094 |
} |
| 1095 |
|
| 1096 |
return $enabled_types; |
| 1097 |
} |
| 1098 |
|
| 1099 |
/** |
| 1100 |
* Apply content schema settings to enabled types |
| 1101 |
* |
| 1102 |
* @since 1.0.0 |
| 1103 |
* |
| 1104 |
* @param array $enabled_types Current enabled types |
| 1105 |
* @param array $settings Schema settings |
| 1106 |
* @param string $context_type Context type |
| 1107 |
* @return array Enhanced enabled types |
| 1108 |
*/ |
| 1109 |
private function apply_content_schema_settings(array $enabled_types, array $settings, string $context_type): array { |
| 1110 |
// For site context: only apply site-level schema settings |
| 1111 |
if ($context_type === 'site') { |
| 1112 |
// Add local business schema if enabled |
| 1113 |
if ($settings['enable_local_business'] ?? false) { |
| 1114 |
if (!in_array('LocalBusiness', $enabled_types, true)) { |
| 1115 |
$enabled_types[] = 'LocalBusiness'; |
| 1116 |
} |
| 1117 |
} |
| 1118 |
|
| 1119 |
// Add breadcrumbs schema if enabled (site-wide feature) |
| 1120 |
if ($settings['enable_breadcrumbs_schema'] ?? false) { |
| 1121 |
if (!in_array('BreadcrumbList', $enabled_types, true)) { |
| 1122 |
$enabled_types[] = 'BreadcrumbList'; |
| 1123 |
} |
| 1124 |
} |
| 1125 |
|
| 1126 |
return $enabled_types; |
| 1127 |
} |
| 1128 |
|
| 1129 |
// For post/page context: apply all schema settings (metabox functionality) |
| 1130 |
|
| 1131 |
// Add article schema if enabled and context is appropriate |
| 1132 |
if ($settings['enable_article_schema'] ?? false) { |
| 1133 |
if (in_array($context_type, ['post', 'page'], true) && !in_array('Article', $enabled_types, true)) { |
| 1134 |
$enabled_types[] = 'Article'; |
| 1135 |
} |
| 1136 |
} |
| 1137 |
|
| 1138 |
// Add FAQ schema if enabled |
| 1139 |
if ($settings['enable_faq_schema'] ?? false) { |
| 1140 |
if (!in_array('FAQPage', $enabled_types, true)) { |
| 1141 |
$enabled_types[] = 'FAQPage'; |
| 1142 |
} |
| 1143 |
} |
| 1144 |
|
| 1145 |
// Add How-To schema if enabled |
| 1146 |
if ($settings['enable_howto_schema'] ?? false) { |
| 1147 |
if (!in_array('HowTo', $enabled_types, true)) { |
| 1148 |
$enabled_types[] = 'HowTo'; |
| 1149 |
} |
| 1150 |
} |
| 1151 |
|
| 1152 |
// Add product schema if enabled and context is appropriate |
| 1153 |
if ($settings['enable_product_schema'] ?? false) { |
| 1154 |
if ($context_type === 'product' && !in_array('Product', $enabled_types, true)) { |
| 1155 |
$enabled_types[] = 'Product'; |
| 1156 |
} |
| 1157 |
} |
| 1158 |
|
| 1159 |
// Add local business schema if enabled |
| 1160 |
if ($settings['enable_local_business'] ?? false) { |
| 1161 |
if (!in_array('LocalBusiness', $enabled_types, true)) { |
| 1162 |
$enabled_types[] = 'LocalBusiness'; |
| 1163 |
} |
| 1164 |
} |
| 1165 |
|
| 1166 |
return $enabled_types; |
| 1167 |
} |
| 1168 |
|
| 1169 |
/** |
| 1170 |
* Get default organization logo for rich snippets |
| 1171 |
* |
| 1172 |
* @since 1.0.0 |
| 1173 |
* |
| 1174 |
* @return string Default logo URL |
| 1175 |
*/ |
| 1176 |
private function get_default_organization_logo(): string { |
| 1177 |
// Try to get custom logo |
| 1178 |
$custom_logo_id = get_theme_mod('custom_logo'); |
| 1179 |
if ($custom_logo_id) { |
| 1180 |
$logo_url = wp_get_attachment_image_url($custom_logo_id, 'full'); |
| 1181 |
if ($logo_url) { |
| 1182 |
return $logo_url; |
| 1183 |
} |
| 1184 |
} |
| 1185 |
|
| 1186 |
// Fallback to site icon or default |
| 1187 |
$site_icon_url = get_site_icon_url(); |
| 1188 |
if ($site_icon_url) { |
| 1189 |
return $site_icon_url; |
| 1190 |
} |
| 1191 |
|
| 1192 |
// Final fallback |
| 1193 |
return home_url('/wp-content/plugins/thinkrank/assets/images/default-logo.jpg'); |
| 1194 |
} |
| 1195 |
|
| 1196 |
/** |
| 1197 |
* Schema keys outside the shared config defaults. |
| 1198 |
* |
| 1199 |
* @since 2.0.1 |
| 1200 |
* |
| 1201 |
* @return string[] |
| 1202 |
*/ |
| 1203 |
protected function additional_setting_keys(): array { |
| 1204 |
return [ |
| 1205 |
'enable_article_schema', 'enable_product_schema', |
| 1206 |
'enable_faq_schema', 'enable_howto_schema', |
| 1207 |
]; |
| 1208 |
} |
| 1209 |
|
| 1210 |
/** |
| 1211 |
* Per-entity schema fields are an open set. |
| 1212 |
* |
| 1213 |
* Each schema type the UI can edit contributes its own field family — |
| 1214 |
* organization_*, person_*, website_*, business_*, software_*, howto_* — |
| 1215 |
* and a new type adds another. The families this manager owns are matched |
| 1216 |
* rather than enumerated, so adding a form does not silently start |
| 1217 |
* dropping its fields (#452). |
| 1218 |
* |
| 1219 |
* @since 2.0.1 |
| 1220 |
* |
| 1221 |
* @return string[] |
| 1222 |
*/ |
| 1223 |
protected function dynamic_setting_key_patterns(): array { |
| 1224 |
return [ |
| 1225 |
'/^organization_[a-z0-9_]+$/', |
| 1226 |
'/^person_[a-z0-9_]+$/', |
| 1227 |
'/^website_[a-z0-9_]+$/', |
| 1228 |
'/^business_[a-z0-9_]+$/', |
| 1229 |
'/^software_[a-z0-9_]+$/', |
| 1230 |
'/^howto_[a-z0-9_]+$/', |
| 1231 |
'/^product_[a-z0-9_]+$/', |
| 1232 |
]; |
| 1233 |
} |
| 1234 |
|
| 1235 |
/** |
| 1236 |
* Get default settings for a context type (implements interface) |
| 1237 |
* |
| 1238 |
* @since 1.0.0 |
| 1239 |
* |
| 1240 |
* @param string $context_type The context type to get defaults for |
| 1241 |
* @return array Default settings array |
| 1242 |
*/ |
| 1243 |
public function get_default_settings(string $context_type): array { |
| 1244 |
return Schema_Settings_Config::get_default_settings($context_type); |
| 1245 |
} |
| 1246 |
|
| 1247 |
/** |
| 1248 |
* Get settings schema definition (implements interface) |
| 1249 |
* |
| 1250 |
* @since 1.0.0 |
| 1251 |
* |
| 1252 |
* @param string $context_type The context type to get schema for |
| 1253 |
* @return array Settings schema definition |
| 1254 |
*/ |
| 1255 |
public function get_settings_schema(string $context_type): array { |
| 1256 |
return Schema_Settings_Config::get_settings_schema($context_type); |
| 1257 |
} |
| 1258 |
|
| 1259 |
/** |
| 1260 |
* Save SEO settings with cache invalidation and auto-deployment |
| 1261 |
* |
| 1262 |
* Overrides parent method to add schema cache invalidation when settings change. |
| 1263 |
* This ensures cached schema data is refreshed when configuration changes. |
| 1264 |
* Also triggers auto-deployment of schema when enabled. |
| 1265 |
* |
| 1266 |
* @since 1.0.0 |
| 1267 |
* |
| 1268 |
* @param string $context_type The context type |
| 1269 |
* @param int|null $context_id Optional. Context ID |
| 1270 |
* @param array $settings Settings array to save |
| 1271 |
* @return bool True on success, false on failure |
| 1272 |
*/ |
| 1273 |
public function save_settings(string $context_type, ?int $context_id, array $settings): bool { |
| 1274 |
// Call parent method to save settings |
| 1275 |
$success = parent::save_settings($context_type, $context_id, $settings); |
| 1276 |
|
| 1277 |
// CACHE INVALIDATION: Clear all schema cache when settings change |
| 1278 |
if ($success && $this->cache_manager) { |
| 1279 |
$this->cache_manager->invalidate_all_cache(); |
| 1280 |
} |
| 1281 |
|
| 1282 |
// AUTO-DEPLOY: Automatically regenerate and deploy schema when settings change |
| 1283 |
if ($success && !empty($settings['auto_deploy'])) { |
| 1284 |
$this->auto_deploy_schema_on_settings_change($context_type, $context_id, $settings); |
| 1285 |
} |
| 1286 |
|
| 1287 |
return $success; |
| 1288 |
} |
| 1289 |
|
| 1290 |
/** |
| 1291 |
* Auto-deploy schema when settings change |
| 1292 |
* |
| 1293 |
* Automatically regenerates and deploys schema markup when organization or other |
| 1294 |
* schema settings are modified, ensuring the frontend output stays in sync. |
| 1295 |
* |
| 1296 |
* @since 1.0.0 |
| 1297 |
* |
| 1298 |
* @param string $context_type Context type |
| 1299 |
* @param int|null $context_id Context ID |
| 1300 |
* @param array $settings Updated settings |
| 1301 |
* @return void |
| 1302 |
*/ |
| 1303 |
private function auto_deploy_schema_on_settings_change(string $context_type, ?int $context_id, array $settings): void { |
| 1304 |
// Determine which schema types need to be regenerated based on changed settings |
| 1305 |
$schema_types_to_regenerate = []; |
| 1306 |
|
| 1307 |
// Organization schema - regenerate if organization settings changed |
| 1308 |
if ($this->has_organization_settings_changed($settings)) { |
| 1309 |
$schema_types_to_regenerate[] = 'Organization'; |
| 1310 |
} |
| 1311 |
|
| 1312 |
// Website schema - regenerate if website settings changed. The type is |
| 1313 |
// registered as 'WebSite' (capital S) in Schema_Factory / $schema_types; |
| 1314 |
// using 'Website' here made generate_schema_markup() silently skip it. |
| 1315 |
if ($this->has_website_settings_changed($settings)) { |
| 1316 |
$schema_types_to_regenerate[] = 'WebSite'; |
| 1317 |
} |
| 1318 |
|
| 1319 |
// LocalBusiness schema - regenerate if business settings changed |
| 1320 |
if ($this->has_business_settings_changed($settings)) { |
| 1321 |
$schema_types_to_regenerate[] = 'LocalBusiness'; |
| 1322 |
} |
| 1323 |
|
| 1324 |
// Person schema - regenerate if person settings changed |
| 1325 |
if ($this->has_person_settings_changed($settings)) { |
| 1326 |
$schema_types_to_regenerate[] = 'Person'; |
| 1327 |
} |
| 1328 |
|
| 1329 |
// Honour the user's Schema Types selection. Without this the payload |
| 1330 |
// shape alone decided what shipped, so every save deployed all four |
| 1331 |
// types — including ones the user had explicitly deselected (#461). |
| 1332 |
// An empty selection means "auto", so only filter when one is set. |
| 1333 |
$enabled_types = $settings['enabled_schema_types'] ?? $this->get_settings($context_type, $context_id)['enabled_schema_types'] ?? []; |
| 1334 |
|
| 1335 |
if (!empty($enabled_types) && is_array($enabled_types)) { |
| 1336 |
$schema_types_to_regenerate = array_values( |
| 1337 |
array_intersect($schema_types_to_regenerate, $enabled_types) |
| 1338 |
); |
| 1339 |
} |
| 1340 |
|
| 1341 |
// Types that were deployed but are no longer wanted must come back off |
| 1342 |
// the page — deployment used to be additive-only (#464). |
| 1343 |
$this->retire_unselected_schema_types($context_type, $context_id, $enabled_types); |
| 1344 |
|
| 1345 |
// If no schema types need regeneration, return early |
| 1346 |
if (empty($schema_types_to_regenerate)) { |
| 1347 |
return; |
| 1348 |
} |
| 1349 |
|
| 1350 |
// Generate every affected type in ONE call. Generating them one at a |
| 1351 |
// time re-entered store_schema_data() per type, and each pass replaced |
| 1352 |
// the rows written by the previous one, so only the last type survived |
| 1353 |
// (#454). One batch also means one delete and one cache flush. |
| 1354 |
try { |
| 1355 |
$generation_result = $this->generate_schema_markup( |
| 1356 |
$context_type, |
| 1357 |
$context_id, |
| 1358 |
$schema_types_to_regenerate |
| 1359 |
); |
| 1360 |
|
| 1361 |
$deployable = []; |
| 1362 |
foreach ($schema_types_to_regenerate as $schema_type) { |
| 1363 |
// Only deploy what validated — see #470. |
| 1364 |
if (!empty($generation_result['generated_schemas'][$schema_type]) |
| 1365 |
&& !empty($generation_result['validation_results'][$schema_type]['is_valid']) |
| 1366 |
) { |
| 1367 |
$deployable[$schema_type] = $generation_result['generated_schemas'][$schema_type]; |
| 1368 |
} |
| 1369 |
} |
| 1370 |
|
| 1371 |
if (!empty($deployable)) { |
| 1372 |
$this->deploy_schema_markup($context_type, $context_id, $deployable); |
| 1373 |
} |
| 1374 |
} catch (\Exception $e) { |
| 1375 |
// Log error but don't fail the settings save |
| 1376 |
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { |
| 1377 |
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log |
| 1378 |
error_log('ThinkRank: Auto-deploy failed for ' . implode(', ', $schema_types_to_regenerate) . ': ' . $e->getMessage()); |
| 1379 |
} |
| 1380 |
} |
| 1381 |
} |
| 1382 |
|
| 1383 |
/** |
| 1384 |
* Check if organization settings have changed |
| 1385 |
* |
| 1386 |
* @since 1.0.0 |
| 1387 |
* |
| 1388 |
* @param array $settings Updated settings |
| 1389 |
* @return bool True if organization settings changed |
| 1390 |
*/ |
| 1391 |
private function has_organization_settings_changed(array $settings): bool { |
| 1392 |
$org_keys = [ |
| 1393 |
'organization_name', 'organization_type', 'organization_logo', 'organization_url', |
| 1394 |
'organization_description', 'organization_social_facebook', 'organization_social_twitter', |
| 1395 |
'organization_social_linkedin', 'organization_social_instagram', 'organization_social_youtube', |
| 1396 |
'organization_social_pinterest', 'organization_social_whatsapp', 'organization_social_telegram', |
| 1397 |
'organization_contact_type', 'organization_contact_phone', 'organization_contact_email', |
| 1398 |
'organization_contact_hours' |
| 1399 |
]; |
| 1400 |
|
| 1401 |
foreach ($org_keys as $key) { |
| 1402 |
if (isset($settings[$key])) { |
| 1403 |
return true; |
| 1404 |
} |
| 1405 |
} |
| 1406 |
|
| 1407 |
return false; |
| 1408 |
} |
| 1409 |
|
| 1410 |
/** |
| 1411 |
* Check if website settings have changed |
| 1412 |
* |
| 1413 |
* @since 1.0.0 |
| 1414 |
* |
| 1415 |
* @param array $settings Updated settings |
| 1416 |
* @return bool True if website settings changed |
| 1417 |
*/ |
| 1418 |
private function has_website_settings_changed(array $settings): bool { |
| 1419 |
// These are the keys the Website tab actually stores. It previously |
| 1420 |
// looked for site_name/site_description/site_url, which belong to Site |
| 1421 |
// Identity and never appear in a schema settings payload — so WebSite |
| 1422 |
// schema never auto-deployed no matter what was edited (#455). |
| 1423 |
$website_keys = [ |
| 1424 |
'website_name', 'website_url', 'website_description', 'website_author', |
| 1425 |
]; |
| 1426 |
|
| 1427 |
foreach ($website_keys as $key) { |
| 1428 |
if (isset($settings[$key])) { |
| 1429 |
return true; |
| 1430 |
} |
| 1431 |
} |
| 1432 |
|
| 1433 |
return false; |
| 1434 |
} |
| 1435 |
|
| 1436 |
/** |
| 1437 |
* Check if business settings have changed |
| 1438 |
* |
| 1439 |
* @since 1.0.0 |
| 1440 |
* |
| 1441 |
* @param array $settings Updated settings |
| 1442 |
* @return bool True if business settings changed |
| 1443 |
*/ |
| 1444 |
private function has_business_settings_changed(array $settings): bool { |
| 1445 |
// Only the keys this manager actually stores. business_name/address/ |
| 1446 |
// phone/hours live in the site_identity category and never reach a |
| 1447 |
// schema settings save, so keying off them meant LocalBusiness never |
| 1448 |
// auto-deployed (#455). Edits to those fields refresh LocalBusiness |
| 1449 |
// through the Site Identity save path instead — see |
| 1450 |
// refresh_schema_for_foreign_settings(). |
| 1451 |
$business_keys = [ |
| 1452 |
'enable_local_business', |
| 1453 |
'business_price_range', |
| 1454 |
'business_geo_latitude', |
| 1455 |
'business_geo_longitude', |
| 1456 |
'business_opening_hours', |
| 1457 |
]; |
| 1458 |
|
| 1459 |
foreach ($business_keys as $key) { |
| 1460 |
if (isset($settings[$key])) { |
| 1461 |
return true; |
| 1462 |
} |
| 1463 |
} |
| 1464 |
|
| 1465 |
return false; |
| 1466 |
} |
| 1467 |
|
| 1468 |
/** |
| 1469 |
* Check if person settings have changed |
| 1470 |
* |
| 1471 |
* @since 1.0.0 |
| 1472 |
* |
| 1473 |
* @param array $settings Updated settings |
| 1474 |
* @return bool True if person settings changed |
| 1475 |
*/ |
| 1476 |
private function has_person_settings_changed(array $settings): bool { |
| 1477 |
$person_keys = ['person_name', 'person_image', 'person_job_title', 'person_description']; |
| 1478 |
|
| 1479 |
foreach ($person_keys as $key) { |
| 1480 |
if (isset($settings[$key])) { |
| 1481 |
return true; |
| 1482 |
} |
| 1483 |
} |
| 1484 |
|
| 1485 |
return false; |
| 1486 |
} |
| 1487 |
|
| 1488 |
/** |
| 1489 |
* Auto-detect appropriate schema types for context |
| 1490 |
* |
| 1491 |
* @since 1.0.0 |
| 1492 |
* |
| 1493 |
* @param string $context_type Context type |
| 1494 |
* @param int|null $context_id Context ID |
| 1495 |
* @return array Detected schema types |
| 1496 |
*/ |
| 1497 |
private function auto_detect_schema_types(string $context_type, ?int $context_id): array { |
| 1498 |
$detected_types = []; |
| 1499 |
|
| 1500 |
switch ($context_type) { |
| 1501 |
case 'site': |
| 1502 |
// The admin's Schema Types selection is the answer to "what |
| 1503 |
// does this site need"; detection is only the fallback for an |
| 1504 |
// install that has not chosen yet (#456). |
| 1505 |
$settings = $this->get_settings($context_type, $context_id); |
| 1506 |
$enabled = array_values(array_filter( |
| 1507 |
array_map('strval', (array) ($settings['enabled_schema_types'] ?? [])), |
| 1508 |
'strlen' |
| 1509 |
)); |
| 1510 |
|
| 1511 |
// Drop stale names the factory no longer registers rather than |
| 1512 |
// handing them to the builder to silently skip. |
| 1513 |
$enabled = array_values(array_filter( |
| 1514 |
$enabled, |
| 1515 |
fn($type) => isset($this->schema_types[$type]) |
| 1516 |
)); |
| 1517 |
|
| 1518 |
if (!empty($enabled)) { |
| 1519 |
$detected_types = $enabled; |
| 1520 |
break; |
| 1521 |
} |
| 1522 |
|
| 1523 |
$detected_types = ['Organization']; |
| 1524 |
// Check if it's a local business |
| 1525 |
if ($this->is_local_business()) { |
| 1526 |
$detected_types[] = 'LocalBusiness'; |
| 1527 |
} |
| 1528 |
break; |
| 1529 |
case 'post': |
| 1530 |
$detected_types = ['Article']; |
| 1531 |
// Check content type for specific article types |
| 1532 |
if ($context_id) { |
| 1533 |
$post = get_post($context_id); |
| 1534 |
if ($post && $this->is_how_to_content($post->post_content)) { |
| 1535 |
$detected_types[] = 'HowTo'; |
| 1536 |
} |
| 1537 |
} |
| 1538 |
break; |
| 1539 |
case 'page': |
| 1540 |
$detected_types = ['Article']; |
| 1541 |
if ($context_id) { |
| 1542 |
$page = get_post($context_id); |
| 1543 |
if ($page && $this->is_faq_content($page->post_content)) { |
| 1544 |
$detected_types[] = 'FAQPage'; |
| 1545 |
} |
| 1546 |
} |
| 1547 |
break; |
| 1548 |
case 'product': |
| 1549 |
$detected_types = ['Product']; |
| 1550 |
break; |
| 1551 |
} |
| 1552 |
|
| 1553 |
return $detected_types; |
| 1554 |
} |
| 1555 |
|
| 1556 |
/** |
| 1557 |
* Prepare content data for Schema_Builder |
| 1558 |
* |
| 1559 |
* @since 1.0.0 |
| 1560 |
* |
| 1561 |
* @param string $context_type Context type |
| 1562 |
* @param int|null $context_id Context ID |
| 1563 |
* @param array $content_analysis Content analysis data |
| 1564 |
* @param array $optimization_data Optimization data |
| 1565 |
* @param array $options Generation options (may contain custom content_data) |
| 1566 |
* @return array Prepared content data for schema generation |
| 1567 |
*/ |
| 1568 |
private function prepare_content_data_for_generator(string $context_type, ?int $context_id, array $content_analysis, array $optimization_data, array $options = []): array { |
| 1569 |
$content_data = []; |
| 1570 |
|
| 1571 |
// Handle different context types |
| 1572 |
if ($context_type === 'site') { |
| 1573 |
// Site-level data |
| 1574 |
$content_data = [ |
| 1575 |
'title' => get_bloginfo('name'), |
| 1576 |
'url' => home_url(), |
| 1577 |
'excerpt' => get_bloginfo('description'), |
| 1578 |
'content' => get_bloginfo('description'), |
| 1579 |
'business_data' => $this->get_business_data_from_local_seo(), |
| 1580 |
'site_data' => $this->get_site_data_for_schema(), |
| 1581 |
'social_data' => $this->get_social_data_for_schema() |
| 1582 |
]; |
| 1583 |
} elseif ($context_id && in_array($context_type, ['post', 'page', 'product'], true)) { |
| 1584 |
// Post/page/product data |
| 1585 |
$post = get_post($context_id); |
| 1586 |
if ($post) { |
| 1587 |
$content_data = [ |
| 1588 |
'title' => $post->post_title, |
| 1589 |
'url' => get_permalink($post->ID), |
| 1590 |
'excerpt' => $post->post_excerpt ?: wp_trim_words($post->post_content, 30), |
| 1591 |
'content' => $post->post_content, |
| 1592 |
'author' => [ |
| 1593 |
'name' => get_the_author_meta('display_name', $post->post_author), |
| 1594 |
'url' => get_author_posts_url($post->post_author) |
| 1595 |
], |
| 1596 |
// ISO 8601 with offset. post_date/post_modified are raw |
| 1597 |
// MySQL columns in site-local time with no timezone, which |
| 1598 |
// Google rejects as "Invalid value in field datePublished" |
| 1599 |
// and drops the Article rich result (#465). |
| 1600 |
'date' => get_the_date('c', $post), |
| 1601 |
'modified' => get_the_modified_date('c', $post), |
| 1602 |
'image' => get_the_post_thumbnail_url($post->ID, 'full'), |
| 1603 |
'focus_keywords' => Focus_Keywords::get($post->ID), |
| 1604 |
'business_data' => $this->get_business_data_from_local_seo(), |
| 1605 |
'site_data' => $this->get_site_data_for_schema(), |
| 1606 |
'social_data' => $this->get_social_data_for_schema() |
| 1607 |
]; |
| 1608 |
|
| 1609 |
// Override with custom content data if provided (for metabox usage) |
| 1610 |
if (!empty($options['content_data'])) { |
| 1611 |
$custom_data = $options['content_data']; |
| 1612 |
|
| 1613 |
// Override title if provided and not empty |
| 1614 |
if (!empty($custom_data['title'])) { |
| 1615 |
$content_data['title'] = $custom_data['title']; |
| 1616 |
} |
| 1617 |
|
| 1618 |
// Override excerpt/description if provided and not empty |
| 1619 |
if (!empty($custom_data['description'])) { |
| 1620 |
$content_data['excerpt'] = $custom_data['description']; |
| 1621 |
} |
| 1622 |
|
| 1623 |
// Override content if provided and not empty |
| 1624 |
if (!empty($custom_data['content'])) { |
| 1625 |
$content_data['content'] = $custom_data['content']; |
| 1626 |
} |
| 1627 |
|
| 1628 |
// Override URL if provided and not empty, but ensure it's the post permalink, not admin URL |
| 1629 |
if (!empty($custom_data['post_url'])) { |
| 1630 |
// If the URL is an admin edit URL, convert it to the post permalink |
| 1631 |
if (strpos($custom_data['post_url'], 'wp-admin/post.php') !== false && $context_id) { |
| 1632 |
$content_data['url'] = get_permalink($context_id); |
| 1633 |
} else { |
| 1634 |
$content_data['url'] = $custom_data['post_url']; |
| 1635 |
} |
| 1636 |
} |
| 1637 |
|
| 1638 |
// Add focus keyword(s) if provided |
| 1639 |
if (!empty($custom_data['focus_keywords']) && is_array($custom_data['focus_keywords'])) { |
| 1640 |
$content_data['focus_keywords'] = $custom_data['focus_keywords']; |
| 1641 |
} |
| 1642 |
if (!empty($custom_data['focus_keyword'])) { |
| 1643 |
$content_data['focus_keyword'] = $custom_data['focus_keyword']; |
| 1644 |
} |
| 1645 |
|
| 1646 |
// Add word count if provided (from frontend calculation) |
| 1647 |
if (!empty($custom_data['word_count'])) { |
| 1648 |
$content_data['word_count'] = (int) $custom_data['word_count']; |
| 1649 |
} |
| 1650 |
|
| 1651 |
// CRITICAL: Override site_data fields that take precedence in schema builder |
| 1652 |
// The schema builder checks site_data first, so we need to clear these |
| 1653 |
// to ensure our custom data is used instead |
| 1654 |
if (isset($content_data['site_data'])) { |
| 1655 |
// Clear site-level article settings so custom data takes precedence |
| 1656 |
unset($content_data['site_data']['article_headline']); |
| 1657 |
unset($content_data['site_data']['article_description']); |
| 1658 |
unset($content_data['site_data']['article_author']); |
| 1659 |
} |
| 1660 |
} |
| 1661 |
|
| 1662 |
// Merge schema form data into site_data if provided (for content-specific schemas) |
| 1663 |
if (!empty($options['schema_form_data'])) { |
| 1664 |
$form_data = $options['schema_form_data']; |
| 1665 |
|
| 1666 |
// Ensure site_data exists |
| 1667 |
if (!isset($content_data['site_data'])) { |
| 1668 |
$content_data['site_data'] = []; |
| 1669 |
} |
| 1670 |
|
| 1671 |
// Merge form data into site_data so schema builder can access it |
| 1672 |
$content_data['site_data'] = array_merge($content_data['site_data'], $form_data); |
| 1673 |
} |
| 1674 |
|
| 1675 |
// Simplified: Content analysis moved to separate services |
| 1676 |
// Word count and reading time handled by Schema_Builder directly from content |
| 1677 |
} |
| 1678 |
} |
| 1679 |
|
| 1680 |
return $content_data; |
| 1681 |
} |
| 1682 |
|
| 1683 |
/** |
| 1684 |
* Store schema data in database |
| 1685 |
* |
| 1686 |
* @since 1.0.0 |
| 1687 |
* |
| 1688 |
* @param string $context_type Context type |
| 1689 |
* @param int|null $context_id Context ID |
| 1690 |
* @param array $generation Generation results |
| 1691 |
* @return bool Success status |
| 1692 |
*/ |
| 1693 |
private function store_schema_data(string $context_type, ?int $context_id, array $generation): bool { |
| 1694 |
global $wpdb; |
| 1695 |
|
| 1696 |
$table_name = $wpdb->prefix . 'thinkrank_seo_schema'; |
| 1697 |
|
| 1698 |
// Replace only the types in this batch. Clearing the whole context |
| 1699 |
// destroyed types the caller never asked about — and callers do |
| 1700 |
// regenerate a subset, one type at a time (#454). |
| 1701 |
$generated_types = array_keys($generation['generated_schemas'] ?? []); |
| 1702 |
if (empty($generated_types)) { |
| 1703 |
return false; |
| 1704 |
} |
| 1705 |
$this->delete_existing_schemas($context_type, $context_id, $generated_types); |
| 1706 |
|
| 1707 |
foreach ($generation['generated_schemas'] as $schema_type => $schema_data) { |
| 1708 |
// Prepare schema data with validation status embedded |
| 1709 |
$schema_data_with_validation = $schema_data; |
| 1710 |
$schema_data_with_validation['_validation'] = [ |
| 1711 |
'is_valid' => $generation['validation_results'][$schema_type]['is_valid'], |
| 1712 |
'errors' => $generation['validation_results'][$schema_type]['errors'] ?? [], |
| 1713 |
'warnings' => $generation['validation_results'][$schema_type]['warnings'] ?? [], |
| 1714 |
'score' => $generation['validation_results'][$schema_type]['validation_score'] ?? 0 |
| 1715 |
]; |
| 1716 |
|
| 1717 |
$data = [ |
| 1718 |
'context_type' => $context_type, |
| 1719 |
'context_id' => $context_id, |
| 1720 |
'schema_type' => $schema_type, |
| 1721 |
'schema_data' => wp_json_encode($schema_data_with_validation), |
| 1722 |
'validation_status' => $generation['validation_results'][$schema_type]['is_valid'] ? 'valid' : 'invalid', |
| 1723 |
// Per-type, not batch-wide. deployment_ready is only true when |
| 1724 |
// EVERY type in the batch validated, so one invalid type (a site |
| 1725 |
// with no Business Info makes LocalBusiness invalid) deactivated |
| 1726 |
// all the valid ones alongside it (#470). |
| 1727 |
'is_active' => !empty($generation['validation_results'][$schema_type]['is_valid']) ? 1 : 0 |
| 1728 |
]; |
| 1729 |
|
| 1730 |
// Insert new schema (existing ones were already deleted) |
| 1731 |
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema insertion requires direct database access |
| 1732 |
$wpdb->insert($table_name, $data); |
| 1733 |
} |
| 1734 |
|
| 1735 |
// CACHE INVALIDATION: Clear cache after storing new schema data |
| 1736 |
if ($this->cache_manager) { |
| 1737 |
$this->cache_manager->invalidate_context_cache($context_type, $context_id); |
| 1738 |
} |
| 1739 |
|
| 1740 |
return true; |
| 1741 |
} |
| 1742 |
|
| 1743 |
/** |
| 1744 |
* Calculate validation score |
| 1745 |
* |
| 1746 |
* @since 1.0.0 |
| 1747 |
* |
| 1748 |
* @param array $validation Validation results |
| 1749 |
* @return int Score (0-100) |
| 1750 |
*/ |
| 1751 |
private function calculate_validation_score(array $validation): int { |
| 1752 |
$score = 100; |
| 1753 |
$score -= count($validation['errors'] ?? []) * 20; |
| 1754 |
$score -= count($validation['warnings'] ?? []) * 10; |
| 1755 |
$score -= count($validation['suggestions'] ?? []) * 5; |
| 1756 |
|
| 1757 |
return max(0, (int) round($score)); |
| 1758 |
} |
| 1759 |
|
| 1760 |
/** |
| 1761 |
* Simple implementations for helper methods referenced in the main functions |
| 1762 |
* These would be enhanced with more sophisticated algorithms in production |
| 1763 |
*/ |
| 1764 |
|
| 1765 |
// Removed complex AI integration methods - moved to separate services |
| 1766 |
// Schema management focuses on core structured data generation |
| 1767 |
private function generate_rich_snippets_preview(array $schema_data, string $schema_type): array { |
| 1768 |
return [ |
| 1769 |
'preview_type' => $schema_type, |
| 1770 |
'title' => $schema_data['headline'] ?? $schema_data['name'] ?? 'Title', |
| 1771 |
'description' => $schema_data['description'] ?? 'Description', |
| 1772 |
'image' => $schema_data['image']['url'] ?? $schema_data['image'] ?? null, |
| 1773 |
'additional_info' => $this->extract_additional_info($schema_data, $schema_type) |
| 1774 |
]; |
| 1775 |
} |
| 1776 |
|
| 1777 |
private function extract_additional_info(array $schema_data, string $schema_type): array { |
| 1778 |
$info = []; |
| 1779 |
|
| 1780 |
switch ($schema_type) { |
| 1781 |
case 'Article': |
| 1782 |
if (isset($schema_data['author']['name'])) { |
| 1783 |
$info['author'] = $schema_data['author']['name']; |
| 1784 |
} |
| 1785 |
if (isset($schema_data['datePublished'])) { |
| 1786 |
$timestamp = strtotime($schema_data['datePublished']); |
| 1787 |
if ($timestamp !== false) { |
| 1788 |
$info['date'] = gmdate('M j, Y', $timestamp); |
| 1789 |
} |
| 1790 |
} |
| 1791 |
break; |
| 1792 |
case 'Product': |
| 1793 |
if (isset($schema_data['offers']['price'])) { |
| 1794 |
$info['price'] = $schema_data['offers']['priceCurrency'] . $schema_data['offers']['price']; |
| 1795 |
} |
| 1796 |
if (isset($schema_data['brand']['name'])) { |
| 1797 |
$info['brand'] = $schema_data['brand']['name']; |
| 1798 |
} |
| 1799 |
break; |
| 1800 |
} |
| 1801 |
|
| 1802 |
return $info; |
| 1803 |
} |
| 1804 |
|
| 1805 |
private function generate_schema_optimization_recommendations(array $generated_schemas, array $validation_results): array { |
| 1806 |
$recommendations = []; |
| 1807 |
|
| 1808 |
foreach ($validation_results as $schema_type => $validation) { |
| 1809 |
if (!$validation['is_valid']) { |
| 1810 |
$recommendations[] = [ |
| 1811 |
'type' => 'validation_error', |
| 1812 |
'schema_type' => $schema_type, |
| 1813 |
'priority' => 'high', |
| 1814 |
'message' => "Schema validation failed for {$schema_type}", |
| 1815 |
'action' => 'Fix validation errors before deployment' |
| 1816 |
]; |
| 1817 |
} |
| 1818 |
|
| 1819 |
if (!empty($validation['warnings'])) { |
| 1820 |
// Extract missing properties from warnings |
| 1821 |
$missing_properties = []; |
| 1822 |
foreach ($validation['warnings'] as $warning) { |
| 1823 |
if (strpos($warning, 'Missing recommended property:') === 0) { |
| 1824 |
$property = trim(str_replace('Missing recommended property:', '', $warning)); |
| 1825 |
$missing_properties[] = $property; |
| 1826 |
} |
| 1827 |
} |
| 1828 |
|
| 1829 |
if (!empty($missing_properties)) { |
| 1830 |
$properties_list = implode(', ', $missing_properties); |
| 1831 |
$recommendations[] = [ |
| 1832 |
'type' => 'missing_properties', |
| 1833 |
'schema_type' => $schema_type, |
| 1834 |
'priority' => 'medium', |
| 1835 |
'message' => "Missing recommended properties for {$schema_type}: {$properties_list}", |
| 1836 |
'action' => 'Add these properties to improve rich snippets eligibility' |
| 1837 |
]; |
| 1838 |
} else { |
| 1839 |
$recommendations[] = [ |
| 1840 |
'type' => 'missing_properties', |
| 1841 |
'schema_type' => $schema_type, |
| 1842 |
'priority' => 'medium', |
| 1843 |
'message' => "Missing recommended properties for {$schema_type}", |
| 1844 |
'action' => 'Add recommended properties to improve rich snippets eligibility' |
| 1845 |
]; |
| 1846 |
} |
| 1847 |
} |
| 1848 |
} |
| 1849 |
|
| 1850 |
return $recommendations; |
| 1851 |
} |
| 1852 |
|
| 1853 |
private function check_deployment_readiness(array $validation_results): bool { |
| 1854 |
foreach ($validation_results as $validation) { |
| 1855 |
if (!$validation['is_valid']) { |
| 1856 |
return false; |
| 1857 |
} |
| 1858 |
} |
| 1859 |
return true; |
| 1860 |
} |
| 1861 |
|
| 1862 |
// Content detection helper methods |
| 1863 |
private function is_local_business(): bool { |
| 1864 |
// Simple check - would be enhanced with actual business detection |
| 1865 |
$description = get_bloginfo('description'); |
| 1866 |
$local_keywords = ['restaurant', 'shop', 'store', 'clinic', 'office', 'service']; |
| 1867 |
|
| 1868 |
foreach ($local_keywords as $keyword) { |
| 1869 |
if (stripos($description, $keyword) !== false) { |
| 1870 |
return true; |
| 1871 |
} |
| 1872 |
} |
| 1873 |
|
| 1874 |
return false; |
| 1875 |
} |
| 1876 |
|
| 1877 |
private function is_how_to_content(string $content): bool { |
| 1878 |
$how_to_keywords = ['step', 'how to', 'tutorial', 'guide', 'instructions']; |
| 1879 |
$content_lower = strtolower($content); |
| 1880 |
|
| 1881 |
foreach ($how_to_keywords as $keyword) { |
| 1882 |
if (stripos($content_lower, $keyword) !== false) { |
| 1883 |
return true; |
| 1884 |
} |
| 1885 |
} |
| 1886 |
|
| 1887 |
return false; |
| 1888 |
} |
| 1889 |
|
| 1890 |
private function is_faq_content(string $content): bool { |
| 1891 |
$faq_keywords = ['faq', 'frequently asked', 'questions', 'q:', 'a:']; |
| 1892 |
$content_lower = strtolower($content); |
| 1893 |
|
| 1894 |
foreach ($faq_keywords as $keyword) { |
| 1895 |
if (stripos($content_lower, $keyword) !== false) { |
| 1896 |
return true; |
| 1897 |
} |
| 1898 |
} |
| 1899 |
|
| 1900 |
return false; |
| 1901 |
} |
| 1902 |
|
| 1903 |
private function determine_deployment_method(array $options): string { |
| 1904 |
// Always use JSON-LD as it's the only supported method |
| 1905 |
return 'json_ld'; |
| 1906 |
} |
| 1907 |
|
| 1908 |
private function deploy_single_schema(array $schema, string $schema_type, string $method, string $context_type, ?int $context_id): array { |
| 1909 |
global $wpdb; |
| 1910 |
|
| 1911 |
// Use existing seo_schema table |
| 1912 |
$table_name = $wpdb->prefix . 'thinkrank_seo_schema'; |
| 1913 |
|
| 1914 |
$deployment_data = [ |
| 1915 |
'context_type' => $context_type, |
| 1916 |
'context_id' => $context_id, |
| 1917 |
'schema_type' => $schema_type, |
| 1918 |
'schema_data' => wp_json_encode($schema), |
| 1919 |
'validation_status' => 'deployed', |
| 1920 |
'is_active' => 1 |
| 1921 |
]; |
| 1922 |
|
| 1923 |
// Check if schema already exists for this context and type |
| 1924 |
if (null === $context_id) { |
| 1925 |
// Handle NULL context_id case |
| 1926 |
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema deployment requires direct database access, table name is validated |
| 1927 |
$sql = sprintf( |
| 1928 |
'SELECT schema_id FROM %s WHERE context_type = %%s AND context_id IS NULL AND schema_type = %%s', |
| 1929 |
$table_name |
| 1930 |
); |
| 1931 |
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema deployment requires direct database access |
| 1932 |
$existing = $wpdb->get_var( |
| 1933 |
$wpdb->prepare( |
| 1934 |
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders |
| 1935 |
$sql, |
| 1936 |
$context_type, |
| 1937 |
$schema_type |
| 1938 |
) |
| 1939 |
); |
| 1940 |
} else { |
| 1941 |
// Handle regular context_id case |
| 1942 |
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema deployment requires direct database access, table name is validated |
| 1943 |
$sql = sprintf( |
| 1944 |
'SELECT schema_id FROM %s WHERE context_type = %%s AND context_id = %%d AND schema_type = %%s', |
| 1945 |
$table_name |
| 1946 |
); |
| 1947 |
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema deployment requires direct database access |
| 1948 |
$existing = $wpdb->get_var( |
| 1949 |
$wpdb->prepare( |
| 1950 |
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders |
| 1951 |
$sql, |
| 1952 |
$context_type, |
| 1953 |
$context_id, |
| 1954 |
$schema_type |
| 1955 |
) |
| 1956 |
); |
| 1957 |
} |
| 1958 |
|
| 1959 |
if ($existing) { |
| 1960 |
// Update existing deployment |
| 1961 |
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema update requires direct database access |
| 1962 |
$result = $wpdb->update( |
| 1963 |
$table_name, |
| 1964 |
[ |
| 1965 |
'schema_data' => wp_json_encode($schema), |
| 1966 |
'validation_status' => 'deployed', |
| 1967 |
'is_active' => 1, |
| 1968 |
'updated_at' => current_time('mysql') |
| 1969 |
], |
| 1970 |
['schema_id' => $existing], |
| 1971 |
['%s', '%s', '%d', '%s'], |
| 1972 |
['%d'] |
| 1973 |
); |
| 1974 |
|
| 1975 |
} else { |
| 1976 |
// Insert new deployment |
| 1977 |
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema insertion requires direct database access |
| 1978 |
$result = $wpdb->insert( |
| 1979 |
$table_name, |
| 1980 |
$deployment_data, |
| 1981 |
['%s', '%d', '%s', '%s', '%s', '%d'] |
| 1982 |
); |
| 1983 |
|
| 1984 |
} |
| 1985 |
|
| 1986 |
return [ |
| 1987 |
'deployed' => $result !== false, |
| 1988 |
'method' => $method, |
| 1989 |
'schema_type' => $schema_type, |
| 1990 |
'schema_id' => $existing ?: $wpdb->insert_id |
| 1991 |
]; |
| 1992 |
} |
| 1993 |
|
| 1994 |
/** |
| 1995 |
* Get deployed schemas for frontend integration |
| 1996 |
* |
| 1997 |
* PERFORMANCE OPTIMIZED: This method now uses: |
| 1998 |
* 1. Schema caching layer (90% reduction in database queries) |
| 1999 |
* 2. Window function approach instead of correlated subquery (80-90% query performance improvement) |
| 2000 |
* 3. Composite index: idx_context_schema_active |
| 2001 |
* |
| 2002 |
* @since 1.0.0 |
| 2003 |
* |
| 2004 |
* @param string $context_type Context type |
| 2005 |
* @param int|null $context_id Context ID |
| 2006 |
* @return array Deployed schemas for current context |
| 2007 |
*/ |
| 2008 |
public function get_deployed_schemas(string $context_type = 'site', ?int $context_id = null): array { |
| 2009 |
// CACHE LAYER: Check cache first for immediate 90% performance improvement |
| 2010 |
if ($this->cache_manager) { |
| 2011 |
$cache_key = $this->cache_manager->generate_deployed_schemas_key($context_type, $context_id); |
| 2012 |
$cached_data = $this->cache_manager->get($cache_key); |
| 2013 |
|
| 2014 |
if ($cached_data !== null) { |
| 2015 |
// CACHE FIX: Extract actual data from cache wrapper |
| 2016 |
return isset($cached_data['data']) ? $cached_data['data'] : $cached_data; |
| 2017 |
} |
| 2018 |
} |
| 2019 |
|
| 2020 |
global $wpdb; |
| 2021 |
|
| 2022 |
// Use existing seo_schema table |
| 2023 |
$table_name = $wpdb->prefix . 'thinkrank_seo_schema'; |
| 2024 |
|
| 2025 |
// OPTIMIZED QUERY: Use window function approach to eliminate correlated subquery |
| 2026 |
// This leverages the new composite index: idx_context_schema_active (context_type, schema_type, is_active, created_at DESC) |
| 2027 |
if (null === $context_id) { |
| 2028 |
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema retrieval requires direct database access |
| 2029 |
$sql = sprintf( |
| 2030 |
'SELECT schema_type, schema_data FROM (SELECT schema_type, schema_data, ROW_NUMBER() OVER (PARTITION BY schema_type ORDER BY created_at DESC) as rn FROM %s WHERE context_type = %%s AND context_id IS NULL AND is_active = 1 AND validation_status IN (\'deployed\', \'valid\')) ranked WHERE rn = 1 ORDER BY schema_type', |
| 2031 |
$table_name |
| 2032 |
); |
| 2033 |
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema retrieval requires direct database access |
| 2034 |
$deployed_schemas = $wpdb->get_results( |
| 2035 |
$wpdb->prepare( |
| 2036 |
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders |
| 2037 |
$sql, |
| 2038 |
$context_type |
| 2039 |
), |
| 2040 |
ARRAY_A |
| 2041 |
); |
| 2042 |
} else { |
| 2043 |
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema retrieval requires direct database access |
| 2044 |
$sql = sprintf( |
| 2045 |
'SELECT schema_type, schema_data FROM (SELECT schema_type, schema_data, ROW_NUMBER() OVER (PARTITION BY schema_type ORDER BY created_at DESC) as rn FROM %s WHERE context_type = %%s AND context_id = %%d AND is_active = 1 AND validation_status IN (\'deployed\', \'valid\')) ranked WHERE rn = 1 ORDER BY schema_type', |
| 2046 |
$table_name |
| 2047 |
); |
| 2048 |
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema retrieval requires direct database access |
| 2049 |
$deployed_schemas = $wpdb->get_results( |
| 2050 |
$wpdb->prepare( |
| 2051 |
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders |
| 2052 |
$sql, |
| 2053 |
$context_type, |
| 2054 |
$context_id |
| 2055 |
), |
| 2056 |
ARRAY_A |
| 2057 |
); |
| 2058 |
} |
| 2059 |
|
| 2060 |
// Deliberately no early return on an empty result: it has to reach the |
| 2061 |
// cache write below. Most URLs have no deployed schema, so gating the |
| 2062 |
// write on a non-empty result made the majority of front-end requests |
| 2063 |
// permanent cache misses, re-running a ROW_NUMBER() OVER (PARTITION BY |
| 2064 |
// ...) query with two filesorts on every pageview (#392). |
| 2065 |
$deployed_schemas = $deployed_schemas ?: []; |
| 2066 |
|
| 2067 |
// Process schemas for return |
| 2068 |
$processed_schemas = []; |
| 2069 |
foreach ($deployed_schemas as $deployed_schema) { |
| 2070 |
$schema_data = json_decode($deployed_schema['schema_data'], true); |
| 2071 |
$schema_type = $deployed_schema['schema_type']; |
| 2072 |
|
| 2073 |
if (!empty($schema_data)) { |
| 2074 |
// Remove internal validation metadata before frontend output |
| 2075 |
if (isset($schema_data['_validation'])) { |
| 2076 |
unset($schema_data['_validation']); |
| 2077 |
} |
| 2078 |
|
| 2079 |
// Deployed schema is a snapshot, so rows written before #465 |
| 2080 |
// still carry raw MySQL datetimes. Normalise on read so the |
| 2081 |
// fix reaches existing sites without a migration. |
| 2082 |
$schema_data = $this->normalize_stored_schema($schema_data); |
| 2083 |
|
| 2084 |
// The permalink was frozen at deploy time, so schema deployed |
| 2085 |
// while a post was a draft advertised "?p=123" as both url and |
| 2086 |
// mainEntityOfPage forever — contradicting the node's own @id |
| 2087 |
// and the canonical (#470). Resolve it live instead. |
| 2088 |
$schema_data = $this->refresh_schema_permalink($schema_data, $context_type, $context_id); |
| 2089 |
|
| 2090 |
// schema.org types `sameAs`, `url`, `logo` and `image` as URLs, |
| 2091 |
// but the form stored whatever was typed, so free text entered |
| 2092 |
// in a social-profile field shipped as a sameAs member and made |
| 2093 |
// the whole entity invalid (#480). Drop bad values on read, so |
| 2094 |
// existing sites stop emitting them without a migration. |
| 2095 |
$schema_data = $this->filter_entity_urls($schema_data); |
| 2096 |
|
| 2097 |
$processed_schemas[$schema_type] = [ |
| 2098 |
'data' => $schema_data, |
| 2099 |
'method' => 'json_ld', // Default method |
| 2100 |
'type' => $schema_type |
| 2101 |
]; |
| 2102 |
} |
| 2103 |
} |
| 2104 |
|
| 2105 |
// CACHE LAYER: Store result in cache for future requests — including |
| 2106 |
// an empty one. Cache_Manager::set() wraps the payload in a metadata |
| 2107 |
// envelope, so an empty result is still stored as a truthy value and |
| 2108 |
// reads back as a hit rather than a miss (#392). |
| 2109 |
if ($this->cache_manager) { |
| 2110 |
$cache_key = $this->cache_manager->generate_deployed_schemas_key($context_type, $context_id); |
| 2111 |
$this->cache_manager->set($cache_key, $processed_schemas); |
| 2112 |
} |
| 2113 |
|
| 2114 |
return $processed_schemas; |
| 2115 |
} |
| 2116 |
|
| 2117 |
/** |
| 2118 |
* Properties schema.org defines as URLs. |
| 2119 |
* |
| 2120 |
* @since 2.0.2 |
| 2121 |
* @var string[] |
| 2122 |
*/ |
| 2123 |
private const URL_PROPERTIES = ['sameAs', 'url', 'logo', 'image']; |
| 2124 |
|
| 2125 |
/** |
| 2126 |
* Whether a value is a URL safe to publish in structured data. |
| 2127 |
* |
| 2128 |
* @since 2.0.2 |
| 2129 |
* |
| 2130 |
* @param mixed $url Candidate value. |
| 2131 |
* @return bool |
| 2132 |
*/ |
| 2133 |
private function is_publishable_url($url): bool { |
| 2134 |
if (!is_string($url) || '' === trim($url)) { |
| 2135 |
return false; |
| 2136 |
} |
| 2137 |
|
| 2138 |
if (!filter_var($url, FILTER_VALIDATE_URL)) { |
| 2139 |
return false; |
| 2140 |
} |
| 2141 |
|
| 2142 |
$scheme = wp_parse_url($url, PHP_URL_SCHEME); |
| 2143 |
|
| 2144 |
return in_array(strtolower((string) $scheme), ['http', 'https'], true); |
| 2145 |
} |
| 2146 |
|
| 2147 |
/** |
| 2148 |
* Drop values that are not URLs from URL-typed properties. |
| 2149 |
* |
| 2150 |
* An absent property is valid; one holding free text is not, and it can |
| 2151 |
* invalidate the entity around it. Nested objects (`logo` and `image` are |
| 2152 |
* frequently ImageObjects) are walked so a bad `url` inside one is caught |
| 2153 |
* too. A property left with nothing is removed rather than emitted empty. |
| 2154 |
* |
| 2155 |
* @since 2.0.2 |
| 2156 |
* |
| 2157 |
* @param array $schema Decoded schema data. |
| 2158 |
* @return array Schema carrying only publishable URLs. |
| 2159 |
*/ |
| 2160 |
private function filter_entity_urls(array $schema): array { |
| 2161 |
foreach ($schema as $key => $value) { |
| 2162 |
if (is_array($value) && !in_array($key, self::URL_PROPERTIES, true)) { |
| 2163 |
$schema[$key] = $this->filter_entity_urls($value); |
| 2164 |
continue; |
| 2165 |
} |
| 2166 |
|
| 2167 |
if (!in_array($key, self::URL_PROPERTIES, true)) { |
| 2168 |
continue; |
| 2169 |
} |
| 2170 |
|
| 2171 |
// A nested object (ImageObject and friends) carries its own url. |
| 2172 |
if (is_array($value) && isset($value['@type'])) { |
| 2173 |
$schema[$key] = $this->filter_entity_urls($value); |
| 2174 |
continue; |
| 2175 |
} |
| 2176 |
|
| 2177 |
if (is_array($value)) { |
| 2178 |
$kept = []; |
| 2179 |
|
| 2180 |
foreach ($value as $item) { |
| 2181 |
if (is_array($item)) { |
| 2182 |
$kept[] = $this->filter_entity_urls($item); |
| 2183 |
} elseif ($this->is_publishable_url($item)) { |
| 2184 |
$kept[] = $item; |
| 2185 |
} |
| 2186 |
} |
| 2187 |
|
| 2188 |
if ([] === $kept) { |
| 2189 |
unset($schema[$key]); |
| 2190 |
} else { |
| 2191 |
$schema[$key] = array_values($kept); |
| 2192 |
} |
| 2193 |
|
| 2194 |
continue; |
| 2195 |
} |
| 2196 |
|
| 2197 |
if (!$this->is_publishable_url($value)) { |
| 2198 |
unset($schema[$key]); |
| 2199 |
} |
| 2200 |
} |
| 2201 |
|
| 2202 |
return $schema; |
| 2203 |
} |
| 2204 |
|
| 2205 |
/** |
| 2206 |
* Schema types whose `url` identifies the entity, not the page. |
| 2207 |
* |
| 2208 |
* On a Person or an Organization, `url` is that entity's own website, so |
| 2209 |
* overwriting it with the permalink of whichever post the schema happens to |
| 2210 |
* be deployed on is simply wrong. It also breaks graph assembly: the site |
| 2211 |
* identity emits the same entity with its real `url`, and once the two |
| 2212 |
* copies disagree they can no longer be recognised as one entity (#479). |
| 2213 |
* |
| 2214 |
* @since 2.0.2 |
| 2215 |
* @var string[] |
| 2216 |
*/ |
| 2217 |
private const ENTITY_URL_TYPES = ['Person', 'Organization', 'LocalBusiness']; |
| 2218 |
|
| 2219 |
/** |
| 2220 |
* Replace a stored permalink snapshot with the post's live permalink. |
| 2221 |
* |
| 2222 |
* Only touches `url` and `mainEntityOfPage`, and only for post-like |
| 2223 |
* contexts where a permalink actually exists. Identity entities are |
| 2224 |
* exempt from the `url` rewrite — see self::ENTITY_URL_TYPES. |
| 2225 |
* |
| 2226 |
* @since 1.16.0 |
| 2227 |
* |
| 2228 |
* @param array $schema Decoded schema data. |
| 2229 |
* @param string $context_type Context type. |
| 2230 |
* @param int|null $context_id Context ID. |
| 2231 |
* @return array Schema with a current permalink. |
| 2232 |
*/ |
| 2233 |
private function refresh_schema_permalink(array $schema, string $context_type, ?int $context_id): array { |
| 2234 |
if ('site' === $context_type || empty($context_id)) { |
| 2235 |
return $schema; |
| 2236 |
} |
| 2237 |
|
| 2238 |
$permalink = get_permalink($context_id); |
| 2239 |
|
| 2240 |
if (!$permalink) { |
| 2241 |
return $schema; |
| 2242 |
} |
| 2243 |
|
| 2244 |
$type = $schema['@type'] ?? ''; |
| 2245 |
$type = is_array($type) ? reset($type) : $type; |
| 2246 |
$is_entity = in_array((string) $type, self::ENTITY_URL_TYPES, true); |
| 2247 |
|
| 2248 |
if (isset($schema['url']) && !$is_entity) { |
| 2249 |
$schema['url'] = $permalink; |
| 2250 |
} |
| 2251 |
|
| 2252 |
if (isset($schema['mainEntityOfPage'])) { |
| 2253 |
if (is_array($schema['mainEntityOfPage'])) { |
| 2254 |
if (isset($schema['mainEntityOfPage']['@id'])) { |
| 2255 |
$schema['mainEntityOfPage']['@id'] = $permalink; |
| 2256 |
} |
| 2257 |
} else { |
| 2258 |
$schema['mainEntityOfPage'] = $permalink; |
| 2259 |
} |
| 2260 |
} |
| 2261 |
|
| 2262 |
return $schema; |
| 2263 |
} |
| 2264 |
|
| 2265 |
/** |
| 2266 |
* Normalise properties that stored snapshots may hold in a stale format. |
| 2267 |
* |
| 2268 |
* Deployed schema is written once and read forever, so a formatting fix in |
| 2269 |
* the builder never reaches rows already on disk. Correcting on read means |
| 2270 |
* existing sites benefit without a migration. |
| 2271 |
* |
| 2272 |
* Covers non-ISO-8601 dates (#465) and WP locales in inLanguage, which must |
| 2273 |
* be a BCP-47 tag — en-US, not en_US (#473). Walks nested nodes so values |
| 2274 |
* inside author/publisher/@graph entries are covered too. |
| 2275 |
* |
| 2276 |
* @since 1.16.0 |
| 2277 |
* |
| 2278 |
* @param array $schema Decoded schema data. |
| 2279 |
* @return array Normalised schema. |
| 2280 |
*/ |
| 2281 |
private function normalize_stored_schema(array $schema): array { |
| 2282 |
static $date_keys = [ |
| 2283 |
'datePublished', 'dateModified', 'dateCreated', 'uploadDate', |
| 2284 |
'startDate', 'endDate', 'validFrom', 'validThrough', 'expires', |
| 2285 |
]; |
| 2286 |
|
| 2287 |
foreach ($schema as $key => $value) { |
| 2288 |
if (is_array($value)) { |
| 2289 |
$schema[$key] = $this->normalize_stored_schema($value); |
| 2290 |
continue; |
| 2291 |
} |
| 2292 |
|
| 2293 |
if ('inLanguage' === $key && is_string($value) && '' !== $value) { |
| 2294 |
$schema[$key] = str_replace('_', '-', $value); |
| 2295 |
continue; |
| 2296 |
} |
| 2297 |
|
| 2298 |
if (!in_array($key, $date_keys, true) || !is_string($value) || '' === $value) { |
| 2299 |
continue; |
| 2300 |
} |
| 2301 |
|
| 2302 |
// Already ISO 8601 — leave it alone. |
| 2303 |
if (preg_match('/^\d{4}-\d{2}-\d{2}T/', $value)) { |
| 2304 |
continue; |
| 2305 |
} |
| 2306 |
|
| 2307 |
$timestamp = strtotime($value); |
| 2308 |
|
| 2309 |
if (false !== $timestamp) { |
| 2310 |
$schema[$key] = (string) wp_date('c', $timestamp); |
| 2311 |
} |
| 2312 |
} |
| 2313 |
|
| 2314 |
return $schema; |
| 2315 |
} |
| 2316 |
|
| 2317 |
/** |
| 2318 |
* Clean up duplicate schemas in database |
| 2319 |
* |
| 2320 |
* @since 1.0.0 |
| 2321 |
* |
| 2322 |
* @param string $context_type Context type |
| 2323 |
* @param int|null $context_id Context ID |
| 2324 |
* @return int Number of duplicate schemas removed |
| 2325 |
*/ |
| 2326 |
public function cleanup_duplicate_schemas(string $context_type = 'site', ?int $context_id = null): int { |
| 2327 |
global $wpdb; |
| 2328 |
|
| 2329 |
$table_name = $wpdb->prefix . 'thinkrank_seo_schema'; |
| 2330 |
|
| 2331 |
if (null === $context_id) { |
| 2332 |
// Clean up duplicates for NULL context_id |
| 2333 |
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema cleanup requires direct database access |
| 2334 |
$sql = sprintf( |
| 2335 |
'DELETE t1 FROM %s t1 INNER JOIN %s t2 WHERE t1.context_type = %%s AND t1.context_id IS NULL AND t2.context_type = %%s AND t2.context_id IS NULL AND t1.schema_type = t2.schema_type AND t1.created_at < t2.created_at', |
| 2336 |
$table_name, |
| 2337 |
$table_name |
| 2338 |
); |
| 2339 |
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema cleanup requires direct database access |
| 2340 |
$deleted = $wpdb->query( |
| 2341 |
$wpdb->prepare( |
| 2342 |
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders |
| 2343 |
$sql, |
| 2344 |
$context_type, |
| 2345 |
$context_type |
| 2346 |
) |
| 2347 |
); |
| 2348 |
} else { |
| 2349 |
// Clean up duplicates for specific context_id |
| 2350 |
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema cleanup requires direct database access |
| 2351 |
$sql = sprintf( |
| 2352 |
'DELETE t1 FROM %s t1 INNER JOIN %s t2 WHERE t1.context_type = %%s AND t1.context_id = %%d AND t2.context_type = %%s AND t2.context_id = %%d AND t1.schema_type = t2.schema_type AND t1.created_at < t2.created_at', |
| 2353 |
$table_name, |
| 2354 |
$table_name |
| 2355 |
); |
| 2356 |
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema cleanup requires direct database access |
| 2357 |
$deleted = $wpdb->query( |
| 2358 |
$wpdb->prepare( |
| 2359 |
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders |
| 2360 |
$sql, |
| 2361 |
$context_type, |
| 2362 |
$context_id, |
| 2363 |
$context_type, |
| 2364 |
$context_id |
| 2365 |
) |
| 2366 |
); |
| 2367 |
} |
| 2368 |
|
| 2369 |
return $deleted ?: 0; |
| 2370 |
} |
| 2371 |
|
| 2372 |
/** |
| 2373 |
* Deactivate deployed schema rows for the given types. |
| 2374 |
* |
| 2375 |
* Deployment was insert-only, so anything ever deployed to a context stayed |
| 2376 |
* on the page forever — switching a post's schema type left the old one live |
| 2377 |
* and deactivating a saved schema did nothing (#464). Rows are deactivated |
| 2378 |
* rather than deleted so a later redeploy can revive them and so there is a |
| 2379 |
* trail of what was published. |
| 2380 |
* |
| 2381 |
* @since 1.16.0 |
| 2382 |
* |
| 2383 |
* @param string $context_type Context type. |
| 2384 |
* @param int|null $context_id Context ID. |
| 2385 |
* @param string[] $schema_types Types to retire. |
| 2386 |
* @return int Number of rows deactivated. |
| 2387 |
*/ |
| 2388 |
private function retire_schema_types(string $context_type, ?int $context_id, array $schema_types): int { |
| 2389 |
$schema_types = array_values(array_filter(array_map('strval', $schema_types), 'strlen')); |
| 2390 |
|
| 2391 |
if (empty($schema_types)) { |
| 2392 |
return 0; |
| 2393 |
} |
| 2394 |
|
| 2395 |
global $wpdb; |
| 2396 |
|
| 2397 |
$table_name = $wpdb->prefix . 'thinkrank_seo_schema'; |
| 2398 |
$placeholders = implode(', ', array_fill(0, count($schema_types), '%s')); |
| 2399 |
|
| 2400 |
if (null === $context_id) { |
| 2401 |
$sql = sprintf( |
| 2402 |
'UPDATE %s SET is_active = 0 WHERE context_type = %%s AND context_id IS NULL AND schema_type IN (%s)', |
| 2403 |
$table_name, |
| 2404 |
$placeholders |
| 2405 |
); |
| 2406 |
$args = array_merge([$context_type], $schema_types); |
| 2407 |
} else { |
| 2408 |
$sql = sprintf( |
| 2409 |
'UPDATE %s SET is_active = 0 WHERE context_type = %%s AND context_id = %%d AND schema_type IN (%s)', |
| 2410 |
$table_name, |
| 2411 |
$placeholders |
| 2412 |
); |
| 2413 |
$args = array_merge([$context_type, $context_id], $schema_types); |
| 2414 |
} |
| 2415 |
|
| 2416 |
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Retiring deployed schema rows requires direct database access. |
| 2417 |
$updated = $wpdb->query( |
| 2418 |
$wpdb->prepare( |
| 2419 |
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is built from an internal table name and generated placeholders. |
| 2420 |
$sql, |
| 2421 |
$args |
| 2422 |
) |
| 2423 |
); |
| 2424 |
|
| 2425 |
if ($updated && $this->cache_manager) { |
| 2426 |
$this->cache_manager->invalidate_context_cache($context_type, $context_id); |
| 2427 |
} |
| 2428 |
|
| 2429 |
return (int) ($updated ?: 0); |
| 2430 |
} |
| 2431 |
|
| 2432 |
/** |
| 2433 |
* Retire deployed types that are no longer in the user's Schema Types selection. |
| 2434 |
* |
| 2435 |
* An empty selection means "auto-detect", so nothing is retired in that case. |
| 2436 |
* |
| 2437 |
* @since 1.16.0 |
| 2438 |
* |
| 2439 |
* @param string $context_type Context type. |
| 2440 |
* @param int|null $context_id Context ID. |
| 2441 |
* @param array $enabled_types The user's selected types. |
| 2442 |
* @return int Number of rows deactivated. |
| 2443 |
*/ |
| 2444 |
private function retire_unselected_schema_types(string $context_type, ?int $context_id, array $enabled_types): int { |
| 2445 |
if (empty($enabled_types)) { |
| 2446 |
return 0; |
| 2447 |
} |
| 2448 |
|
| 2449 |
$deployed = array_keys($this->get_deployed_schemas($context_type, $context_id)); |
| 2450 |
$stale = array_diff($deployed, $enabled_types); |
| 2451 |
|
| 2452 |
return $this->retire_schema_types($context_type, $context_id, $stale); |
| 2453 |
} |
| 2454 |
|
| 2455 |
/** |
| 2456 |
* Delete stored schemas for a context before storing new ones. |
| 2457 |
* |
| 2458 |
* `$schema_types` scopes the delete to the types actually being rewritten. |
| 2459 |
* Without it this wiped every type in the context, which silently destroyed |
| 2460 |
* deployed schema whenever a caller regenerated a subset — and |
| 2461 |
* auto_deploy_schema_on_settings_change() regenerates one type at a time |
| 2462 |
* (#454). Passing an empty array keeps the original clear-the-context |
| 2463 |
* behaviour for callers that genuinely rewrite everything. |
| 2464 |
* |
| 2465 |
* @since 1.0.0 |
| 2466 |
* |
| 2467 |
* @param string $context_type Context type |
| 2468 |
* @param int|null $context_id Context ID |
| 2469 |
* @param string[] $schema_types Optional. Limit the delete to these types. |
| 2470 |
* @return int Number of schemas deleted |
| 2471 |
*/ |
| 2472 |
private function delete_existing_schemas(string $context_type, ?int $context_id, array $schema_types = []): int { |
| 2473 |
global $wpdb; |
| 2474 |
|
| 2475 |
$table_name = $wpdb->prefix . 'thinkrank_seo_schema'; |
| 2476 |
|
| 2477 |
// Build an optional `AND schema_type IN (…)` clause with one prepared |
| 2478 |
// placeholder per type, so the scoping cannot be injected through. |
| 2479 |
$type_clause = ''; |
| 2480 |
$type_values = []; |
| 2481 |
$schema_types = array_values(array_filter(array_map('strval', $schema_types), 'strlen')); |
| 2482 |
if (!empty($schema_types)) { |
| 2483 |
$type_clause = ' AND schema_type IN (' . implode(', ', array_fill(0, count($schema_types), '%s')) . ')'; |
| 2484 |
$type_values = $schema_types; |
| 2485 |
} |
| 2486 |
|
| 2487 |
if (null === $context_id) { |
| 2488 |
// Delete all schemas for NULL context_id |
| 2489 |
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema deletion requires direct database access |
| 2490 |
$sql = sprintf( |
| 2491 |
'DELETE FROM %s WHERE context_type = %%s AND context_id IS NULL%s', |
| 2492 |
$table_name, |
| 2493 |
$type_clause |
| 2494 |
); |
| 2495 |
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema deletion requires direct database access |
| 2496 |
$deleted = $wpdb->query( |
| 2497 |
$wpdb->prepare( |
| 2498 |
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders |
| 2499 |
$sql, |
| 2500 |
array_merge([$context_type], $type_values) |
| 2501 |
) |
| 2502 |
); |
| 2503 |
} else { |
| 2504 |
// Delete all schemas for specific context_id |
| 2505 |
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema deletion requires direct database access |
| 2506 |
$sql = sprintf( |
| 2507 |
'DELETE FROM %s WHERE context_type = %%s AND context_id = %%d%s', |
| 2508 |
$table_name, |
| 2509 |
$type_clause |
| 2510 |
); |
| 2511 |
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema deletion requires direct database access |
| 2512 |
$deleted = $wpdb->query( |
| 2513 |
$wpdb->prepare( |
| 2514 |
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders |
| 2515 |
$sql, |
| 2516 |
array_merge([$context_type, $context_id], $type_values) |
| 2517 |
) |
| 2518 |
); |
| 2519 |
} |
| 2520 |
|
| 2521 |
return $deleted ?: 0; |
| 2522 |
} |
| 2523 |
|
| 2524 |
/** |
| 2525 |
* Get business data from Site Identity Local settings |
| 2526 |
* |
| 2527 |
* @return array |
| 2528 |
*/ |
| 2529 |
private function get_business_data_from_local_seo(): array { |
| 2530 |
// Get Site Identity settings which include Local SEO data |
| 2531 |
$site_identity_settings = get_option('thinkrank_site_identity_settings', []); |
| 2532 |
|
| 2533 |
return [ |
| 2534 |
'business_name' => $site_identity_settings['business_name'] ?? '', |
| 2535 |
'business_address' => $site_identity_settings['business_address'] ?? '', |
| 2536 |
'business_city' => $site_identity_settings['business_city'] ?? '', |
| 2537 |
'business_state' => $site_identity_settings['business_state'] ?? '', |
| 2538 |
'business_postal_code' => $site_identity_settings['business_postal_code'] ?? '', |
| 2539 |
'business_country' => $site_identity_settings['business_country'] ?? '', |
| 2540 |
'business_phone' => $site_identity_settings['business_phone'] ?? '', |
| 2541 |
'business_email' => $site_identity_settings['business_email'] ?? '', |
| 2542 |
'business_hours' => $site_identity_settings['business_hours'] ?? [], |
| 2543 |
'business_type' => $site_identity_settings['business_type'] ?? 'LocalBusiness' |
| 2544 |
]; |
| 2545 |
} |
| 2546 |
public function get_settings(string $context_type, ?int $context_id = null): array { |
| 2547 |
// Get base settings from parent |
| 2548 |
$settings = parent::get_settings($context_type, $context_id); |
| 2549 |
|
| 2550 |
// For site context, automatically include Site Identity data |
| 2551 |
if ($context_type === 'site') { |
| 2552 |
// Get Site Identity settings from the Site Identity Manager |
| 2553 |
$site_identity_manager = new \ThinkRank\SEO\Site_Identity_Manager(); |
| 2554 |
$site_identity_settings = $site_identity_manager->get_settings('site', null); |
| 2555 |
|
| 2556 |
// Include Site Identity assets if not already set in Schema Manager |
| 2557 |
if (empty($settings['logo_url']) && !empty($site_identity_settings['logo_url'])) { |
| 2558 |
$settings['logo_url'] = $site_identity_settings['logo_url']; |
| 2559 |
} |
| 2560 |
if (empty($settings['favicon_url']) && !empty($site_identity_settings['favicon_url'])) { |
| 2561 |
$settings['favicon_url'] = $site_identity_settings['favicon_url']; |
| 2562 |
} |
| 2563 |
if (empty($settings['apple_touch_icon_url']) && !empty($site_identity_settings['apple_touch_icon_url'])) { |
| 2564 |
$settings['apple_touch_icon_url'] = $site_identity_settings['apple_touch_icon_url']; |
| 2565 |
} |
| 2566 |
|
| 2567 |
// Include Site Identity organization data if not already set in Schema Manager |
| 2568 |
if (empty($settings['organization_name']) && !empty($site_identity_settings['site_name'])) { |
| 2569 |
$settings['organization_name'] = $site_identity_settings['site_name']; |
| 2570 |
} |
| 2571 |
if (empty($settings['organization_url']) && !empty($site_identity_settings['site_url'])) { |
| 2572 |
$settings['organization_url'] = $site_identity_settings['site_url']; |
| 2573 |
} |
| 2574 |
if (empty($settings['organization_description']) && !empty($site_identity_settings['site_description'])) { |
| 2575 |
$settings['organization_description'] = $site_identity_settings['site_description']; |
| 2576 |
} |
| 2577 |
} |
| 2578 |
|
| 2579 |
return $settings; |
| 2580 |
} |
| 2581 |
|
| 2582 |
/** |
| 2583 |
* Get site data for rich schema generation |
| 2584 |
* |
| 2585 |
* @return array |
| 2586 |
*/ |
| 2587 |
private function get_site_data_for_schema(): array { |
| 2588 |
// Get Schema Manager's own settings first (highest priority) |
| 2589 |
$schema_settings = $this->get_settings('site', null); |
| 2590 |
|
| 2591 |
// Get Site Identity settings for additional data |
| 2592 |
$site_identity_manager = new \ThinkRank\SEO\Site_Identity_Manager(); |
| 2593 |
$site_identity_settings = $site_identity_manager->get_settings('site', null); |
| 2594 |
|
| 2595 |
return [ |
| 2596 |
'site_name' => get_bloginfo('name'), |
| 2597 |
'site_description' => get_bloginfo('description'), |
| 2598 |
'site_url' => home_url(), |
| 2599 |
'admin_email' => get_option('admin_email'), |
| 2600 |
'language' => get_locale(), |
| 2601 |
'timezone' => get_option('timezone_string'), |
| 2602 |
'founded_date' => $site_identity_settings['founded_date'] ?? '', |
| 2603 |
'founder_name' => $site_identity_settings['founder_name'] ?? '', |
| 2604 |
'company_type' => $site_identity_settings['company_type'] ?? 'Organization', |
| 2605 |
// Site Identity assets |
| 2606 |
'logo_url' => $site_identity_settings['logo_url'] ?? '', |
| 2607 |
'favicon_url' => $site_identity_settings['favicon_url'] ?? '', |
| 2608 |
// Schema Manager organization settings (highest priority) |
| 2609 |
'organization_name' => $schema_settings['organization_name'] ?? '', |
| 2610 |
'organization_description' => $schema_settings['organization_description'] ?? '', |
| 2611 |
'organization_url' => $schema_settings['organization_url'] ?? '', |
| 2612 |
|
| 2613 |
// Removed post/page-specific schema settings (Product, Event, Article, Software Application) |
| 2614 |
// These are now handled only at the post/page level via metabox |
| 2615 |
|
| 2616 |
// Person schema settings (site-wide) |
| 2617 |
'person_name' => $schema_settings['person_name'] ?? '', |
| 2618 |
'person_job_title' => $schema_settings['person_job_title'] ?? '', |
| 2619 |
'person_description' => $schema_settings['person_description'] ?? '', |
| 2620 |
'person_image' => $schema_settings['person_image'] ?? '', |
| 2621 |
'person_url' => $schema_settings['person_url'] ?? '', |
| 2622 |
'person_email' => $schema_settings['person_email'] ?? '', |
| 2623 |
'person_telephone' => $schema_settings['person_telephone'] ?? '', |
| 2624 |
'person_address' => $schema_settings['person_address'] ?? '', |
| 2625 |
'person_birth_date' => $schema_settings['person_birth_date'] ?? '', |
| 2626 |
'person_nationality' => $schema_settings['person_nationality'] ?? '', |
| 2627 |
'person_works_for' => $schema_settings['person_works_for'] ?? '', |
| 2628 |
'person_same_as' => $schema_settings['person_same_as'] ?? [], |
| 2629 |
|
| 2630 |
// Website schema settings (site-wide) |
| 2631 |
'website_name' => $schema_settings['website_name'] ?? '', |
| 2632 |
'website_url' => $schema_settings['website_url'] ?? '', |
| 2633 |
'website_description' => $schema_settings['website_description'] ?? '', |
| 2634 |
'website_author' => $schema_settings['website_author'] ?? '', |
| 2635 |
|
| 2636 |
'organization_logo' => $schema_settings['organization_logo'] ?? '', |
| 2637 |
// Social media links (sameAs) |
| 2638 |
'organization_social_facebook' => $schema_settings['organization_social_facebook'] ?? '', |
| 2639 |
'organization_social_twitter' => $schema_settings['organization_social_twitter'] ?? '', |
| 2640 |
'organization_social_linkedin' => $schema_settings['organization_social_linkedin'] ?? '', |
| 2641 |
'organization_social_instagram' => $schema_settings['organization_social_instagram'] ?? '', |
| 2642 |
'organization_social_youtube' => $schema_settings['organization_social_youtube'] ?? '', |
| 2643 |
'organization_social_pinterest' => $schema_settings['organization_social_pinterest'] ?? '', |
| 2644 |
'organization_social_whatsapp' => $schema_settings['organization_social_whatsapp'] ?? '', |
| 2645 |
'organization_social_telegram' => $schema_settings['organization_social_telegram'] ?? '', |
| 2646 |
// Contact point information |
| 2647 |
'organization_contact_type' => $schema_settings['organization_contact_type'] ?? 'customer service', |
| 2648 |
'organization_contact_phone' => $schema_settings['organization_contact_phone'] ?? '', |
| 2649 |
'organization_contact_email' => $schema_settings['organization_contact_email'] ?? '', |
| 2650 |
'organization_contact_hours' => $schema_settings['organization_contact_hours'] ?? '', |
| 2651 |
// LocalBusiness specific fields |
| 2652 |
'business_price_range' => $schema_settings['business_price_range'] ?? '', |
| 2653 |
'business_geo_latitude' => $schema_settings['business_geo_latitude'] ?? '', |
| 2654 |
'business_geo_longitude' => $schema_settings['business_geo_longitude'] ?? '', |
| 2655 |
'business_opening_hours' => $schema_settings['business_opening_hours'] ?? [] |
| 2656 |
]; |
| 2657 |
} |
| 2658 |
|
| 2659 |
/** |
| 2660 |
* Get social media data for schema generation |
| 2661 |
* |
| 2662 |
* @return array |
| 2663 |
*/ |
| 2664 |
private function get_social_data_for_schema(): array { |
| 2665 |
// Get Social Media settings |
| 2666 |
$social_settings = get_option('thinkrank_social_media_settings', []); |
| 2667 |
|
| 2668 |
$social_profiles = []; |
| 2669 |
|
| 2670 |
// Common social platforms |
| 2671 |
$platforms = ['facebook', 'twitter', 'instagram', 'linkedin', 'youtube', 'tiktok', 'pinterest']; |
| 2672 |
|
| 2673 |
foreach ($platforms as $platform) { |
| 2674 |
$url = $social_settings["{$platform}_url"] ?? ''; |
| 2675 |
if (!empty($url)) { |
| 2676 |
$social_profiles[] = $url; |
| 2677 |
} |
| 2678 |
} |
| 2679 |
|
| 2680 |
return [ |
| 2681 |
'social_profiles' => $social_profiles, |
| 2682 |
'social_settings' => $social_settings |
| 2683 |
]; |
| 2684 |
} |
| 2685 |
} |
| 2686 |
|