| @@ -16,8 +16,13 @@ | ||
| 16 | 16 | namespace ThinkRank\SEO; |
| 17 | 17 | |
| 18 | 18 | use ThinkRank\Config\Schema_Settings_Config; |
| 19 | 19 | |
| 20 | +// Prevent direct access | |
| 21 | +if (!defined('ABSPATH')) { | |
| 22 | + exit; | |
| 23 | +} | |
| 24 | + | |
| 20 | 25 | /** |
| 21 | 26 | * Schema Management System Class |
| 22 | 27 | * |
| 23 | 28 | * Provides streamlined schema markup management with generation, validation, |
| @@ -43,17 +48,8 @@ | ||
| 43 | 48 | 'rich_snippets' => ['article', 'news_article', 'blog_posting'], |
| 44 | 49 | 'context_types' => ['post', 'page'], |
| 45 | 50 | 'priority' => 'high' |
| 46 | 51 | ], |
| 47 | - 'BlogPosting' => [ | |
| 48 | - 'name' => 'BlogPosting', | |
| 49 | - 'description' => 'Blog posts and personal articles', | |
| 50 | - 'required_properties' => ['headline', 'author', 'datePublished'], | |
| 51 | - 'recommended_properties' => ['image', 'publisher', 'dateModified', 'mainEntityOfPage'], | |
| 52 | - 'rich_snippets' => ['article', 'blog_posting'], | |
| 53 | - 'context_types' => ['post', 'page'], | |
| 54 | - 'priority' => 'high' | |
| 55 | - ], | |
| 56 | 52 | 'TechnicalArticle' => [ |
| 57 | 53 | 'name' => 'TechnicalArticle', |
| 58 | 54 | 'description' => 'Technical documentation and tutorials', |
| 59 | 55 | 'required_properties' => ['headline', 'author', 'datePublished'], |
| @@ -162,17 +158,29 @@ | ||
| 162 | 158 | 'rich_snippets' => ['event', 'social_event'], |
| 163 | 159 | 'context_types' => ['post', 'page'], |
| 164 | 160 | 'priority' => 'medium' |
| 165 | 161 | ], |
| 166 | - 'FAQPage' => [ | |
| 167 | - 'name' => 'FAQPage', | |
| 168 | - 'description' => 'Frequently Asked Questions pages', | |
| 169 | - 'required_properties' => ['mainEntity'], | |
| 170 | - 'recommended_properties' => ['name', 'description'], | |
| 171 | - 'rich_snippets' => ['faq', 'question'], | |
| 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'], | |
| 172 | 168 | 'context_types' => ['post', 'page'], |
| 173 | 169 | 'priority' => 'medium' |
| 174 | 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 | + ], | |
| 175 | 183 | 'Recipe' => [ |
| 176 | 184 | 'name' => 'Recipe', |
| 177 | 185 | 'description' => 'Cooking recipes and food preparation', |
| 178 | 186 | 'required_properties' => ['name', 'image', 'author', 'datePublished', 'description', 'recipeIngredient', 'recipeInstructions'], |
| @@ -313,8 +321,19 @@ | ||
| 313 | 321 | */ |
| 314 | 322 | private ?Schema_Cache_Manager $cache_manager = null; |
| 315 | 323 | |
| 316 | 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 | + /** | |
| 317 | 336 | * Constructor |
| 318 | 337 | * |
| 319 | 338 | * @since 1.0.0 |
| 320 | 339 | */ |
| @@ -330,11 +349,117 @@ | ||
| 330 | 349 | $this->initialize_schema_builder(); |
| 331 | 350 | |
| 332 | 351 | // Initialize Schema Cache Manager for performance optimization |
| 333 | 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 | + } | |
| 334 | 367 | } |
| 335 | 368 | |
| 336 | 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 | + // Which deployed types a Site Identity key can invalidate. The business | |
| 402 | + // block feeds LocalBusiness and Organization; alternate_name feeds the | |
| 403 | + // WebSite node, which had no entry here at all — so editing it left the | |
| 404 | + // deployed schema showing the previous value until something else | |
| 405 | + // happened to redeploy (#692). | |
| 406 | + $refresh_types = []; | |
| 407 | + | |
| 408 | + if (!empty(array_intersect_key($settings, array_flip($business_keys)))) { | |
| 409 | + $refresh_types[] = 'LocalBusiness'; | |
| 410 | + $refresh_types[] = 'Organization'; | |
| 411 | + } | |
| 412 | + | |
| 413 | + if (array_key_exists('alternate_name', $settings)) { | |
| 414 | + $refresh_types[] = 'WebSite'; | |
| 415 | + } | |
| 416 | + | |
| 417 | + if (empty($refresh_types)) { | |
| 418 | + return; | |
| 419 | + } | |
| 420 | + | |
| 421 | + $schema_settings = $this->get_settings($context_type, $context_id); | |
| 422 | + if (empty($schema_settings['auto_deploy'])) { | |
| 423 | + return; | |
| 424 | + } | |
| 425 | + | |
| 426 | + // Only refresh types that are actually deployed, so this never adds a | |
| 427 | + // type the admin did not enable. | |
| 428 | + $deployed = array_keys((array) $this->get_deployed_schemas($context_type, $context_id)); | |
| 429 | + $affected = array_values(array_intersect($deployed, $refresh_types)); | |
| 430 | + | |
| 431 | + if (empty($affected)) { | |
| 432 | + return; | |
| 433 | + } | |
| 434 | + | |
| 435 | + try { | |
| 436 | + $generation = $this->generate_schema_markup($context_type, $context_id, $affected); | |
| 437 | + | |
| 438 | + // Deploy the types that validated, not all-or-nothing. Gating on | |
| 439 | + // deployment_ready meant one invalid type blocked every valid one | |
| 440 | + // in the same batch (#470). | |
| 441 | + $deployable = []; | |
| 442 | + foreach ($affected as $type) { | |
| 443 | + if (!empty($generation['generated_schemas'][$type]) | |
| 444 | + && !empty($generation['validation_results'][$type]['is_valid']) | |
| 445 | + ) { | |
| 446 | + $deployable[$type] = $generation['generated_schemas'][$type]; | |
| 447 | + } | |
| 448 | + } | |
| 449 | + | |
| 450 | + if (!empty($deployable)) { | |
| 451 | + $this->deploy_schema_markup($context_type, $context_id, $deployable); | |
| 452 | + } | |
| 453 | + } catch (\Exception $e) { | |
| 454 | + if (defined('WP_DEBUG') && WP_DEBUG) { | |
| 455 | + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log | |
| 456 | + error_log('ThinkRank: Business Info schema refresh failed: ' . $e->getMessage()); | |
| 457 | + } | |
| 458 | + } | |
| 459 | + } | |
| 460 | + | |
| 461 | + /** | |
| 337 | 462 | * Initialize Schema Builder |
| 338 | 463 | * |
| 339 | 464 | * @return void |
| 340 | 465 | */ |
| @@ -360,10 +485,20 @@ | ||
| 360 | 485 | require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-schema-cache-manager.php'; |
| 361 | 486 | } |
| 362 | 487 | |
| 363 | 488 | if (class_exists('ThinkRank\\SEO\\Schema_Cache_Manager')) { |
| 364 | - // Get cache duration from deployment config | |
| 489 | + // Honour the stored cache_duration setting. It is exposed in | |
| 490 | + // get_settings_schema() (min 300 / max 86400), validated, persisted | |
| 491 | + // and surfaced through both abilities — but the cache manager was | |
| 492 | + // always built from the hardcoded config value, so the setting had | |
| 493 | + // no effect (#473). Falls back to the config default. | |
| 365 | 494 | $cache_duration = $this->deployment_config['caching']['duration'] ?? 3600; |
| 495 | + | |
| 496 | + $stored = $this->get_settings('site', null)['cache_duration'] ?? null; | |
| 497 | + if (is_numeric($stored) && (int) $stored > 0) { | |
| 498 | + $cache_duration = (int) $stored; | |
| 499 | + } | |
| 500 | + | |
| 366 | 501 | $this->cache_manager = new Schema_Cache_Manager($cache_duration); |
| 367 | 502 | } |
| 368 | 503 | } |
| 369 | 504 | |
| @@ -371,12 +506,19 @@ | ||
| 371 | 506 | * Generate schema markup with comprehensive content analysis integration |
| 372 | 507 | * |
| 373 | 508 | * @since 1.0.0 |
| 374 | 509 | * |
| 510 | + * Generation is read-only by default. Persisting the result is opt-in via | |
| 511 | + * `$options['persist']`, because this method is also reached from the | |
| 512 | + * front-end read path (get_output_data()) and from GET routes — where a | |
| 513 | + * DELETE + INSERT would destroy the admin's deployed rows and publish | |
| 514 | + * types nobody deployed (#460). | |
| 515 | + * | |
| 375 | 516 | * @param string $context_type Context type |
| 376 | 517 | * @param int|null $context_id Context ID |
| 377 | 518 | * @param array $schema_types Schema types to generate |
| 378 | - * @param array $options Generation options | |
| 519 | + * @param array $options Generation options. Pass `persist => true` | |
| 520 | + * from explicit write paths only. | |
| 379 | 521 | * @return array Comprehensive schema generation results |
| 380 | 522 | */ |
| 381 | 523 | public function generate_schema_markup(string $context_type, ?int $context_id, array $schema_types = [], array $options = []): array { |
| 382 | 524 | $generation = [ |
| @@ -447,10 +589,16 @@ | ||
| 447 | 589 | |
| 448 | 590 | // Check deployment readiness |
| 449 | 591 | $generation['deployment_ready'] = $this->check_deployment_readiness($generation['validation_results']); |
| 450 | 592 | |
| 451 | - // Store schema data | |
| 452 | - $this->store_schema_data($context_type, $context_id, $generation); | |
| 593 | + // Persistence belongs to deployment, not generation. Every write path | |
| 594 | + // (refresh_schema_for_foreign_settings(), auto_deploy_schema_on_settings_change(), | |
| 595 | + // the deploy route) calls deploy_schema_markup() straight after generating, | |
| 596 | + // so nothing needs to opt in today — the flag exists to keep this an | |
| 597 | + // explicit decision rather than an accident. | |
| 598 | + if (!empty($options['persist'])) { | |
| 599 | + $this->store_schema_data($context_type, $context_id, $generation); | |
| 600 | + } | |
| 453 | 601 | |
| 454 | 602 | return $generation; |
| 455 | 603 | } |
| 456 | 604 | |
| @@ -578,8 +726,24 @@ | ||
| 578 | 726 | |
| 579 | 727 | // Determine deployment method |
| 580 | 728 | $deployment['deployment_method'] = $this->determine_deployment_method($options); |
| 581 | 729 | |
| 730 | + // When the caller owns the whole context — the user pressing Deploy, where | |
| 731 | + // the payload is exactly what the preview showed — anything not in that | |
| 732 | + // payload should come off the page (#464). Incremental callers such as | |
| 733 | + // auto_deploy_schema_on_settings_change() pass only the types they | |
| 734 | + // regenerated, so they must NOT retire the rest. | |
| 735 | + if (!empty($options['authoritative'])) { | |
| 736 | + $deployment['retired_schemas'] = $this->retire_schema_types( | |
| 737 | + $context_type, | |
| 738 | + $context_id, | |
| 739 | + array_diff( | |
| 740 | + array_keys($this->get_deployed_schemas($context_type, $context_id)), | |
| 741 | + array_keys($schema_data) | |
| 742 | + ) | |
| 743 | + ); | |
| 744 | + } | |
| 745 | + | |
| 582 | 746 | // Deploy each schema |
| 583 | 747 | foreach ($schema_data as $schema_type => $schema) { |
| 584 | 748 | $deploy_result = $this->deploy_single_schema($schema, $schema_type, $deployment['deployment_method'], $context_type, $context_id); |
| 585 | 749 | $deployment['deployed_schemas'][$schema_type] = $deploy_result; |
| @@ -588,21 +752,91 @@ | ||
| 588 | 752 | // Clean up duplicate schemas |
| 589 | 753 | $this->cleanup_duplicate_schemas($context_type, $context_id); |
| 590 | 754 | |
| 591 | 755 | // CACHE INVALIDATION: Clear cache after successful deployment |
| 756 | + $cache_invalidated = false; | |
| 592 | 757 | if ($this->cache_manager && !empty($deployment['deployed_schemas'])) { |
| 593 | 758 | $this->cache_manager->invalidate_context_cache($context_type, $context_id); |
| 759 | + $cache_invalidated = true; | |
| 594 | 760 | } |
| 595 | 761 | |
| 596 | - // Set deployment status based on results | |
| 597 | - $deployment['cache_status'] = ['cache_updated' => true, 'message' => 'Schema cache updated']; | |
| 598 | - $deployment['validation_post_deployment'] = ['validation_passed' => true, 'message' => 'Schema deployed successfully']; | |
| 599 | - $deployment['deployment_status'] = !empty($deployment['deployed_schemas']) ? 'success' : 'failed'; | |
| 762 | + $deployment['cache_status'] = $cache_invalidated | |
| 763 | + ? ['cache_updated' => true, 'message' => 'Schema cache invalidated'] | |
| 764 | + : ['cache_updated' => false, 'message' => 'No schema cache to invalidate']; | |
| 600 | 765 | |
| 766 | + // Post-deployment verification: read back through the same accessor the | |
| 767 | + // front end uses, so a row that was written but is not retrievable (wrong | |
| 768 | + // context, inactive, stale cache) is reported as a failure instead of | |
| 769 | + // being assumed successful. | |
| 770 | + $deployment['validation_post_deployment'] = $this->verify_deployment( | |
| 771 | + $context_type, | |
| 772 | + $context_id, | |
| 773 | + array_keys($deployment['deployed_schemas']) | |
| 774 | + ); | |
| 775 | + | |
| 776 | + $writes_ok = !empty($deployment['deployed_schemas']); | |
| 777 | + foreach ($deployment['deployed_schemas'] as $deploy_result) { | |
| 778 | + if (empty($deploy_result['deployed'])) { | |
| 779 | + $writes_ok = false; | |
| 780 | + break; | |
| 781 | + } | |
| 782 | + } | |
| 783 | + | |
| 784 | + $deployment['deployment_status'] = | |
| 785 | + ($writes_ok && !empty($deployment['validation_post_deployment']['validation_passed'])) | |
| 786 | + ? 'success' | |
| 787 | + : 'failed'; | |
| 788 | + | |
| 601 | 789 | return $deployment; |
| 602 | 790 | } |
| 603 | 791 | |
| 604 | 792 | /** |
| 793 | + * Verify deployed schema is retrievable after a deploy. | |
| 794 | + * | |
| 795 | + * Reads back through get_deployed_schemas() — the same accessor | |
| 796 | + * Frontend\SEO_Manager::output_site_schema_markup() uses to emit schema — so | |
| 797 | + * the check reflects what will actually reach the page rather than only that | |
| 798 | + * an INSERT returned without error. | |
| 799 | + * | |
| 800 | + * @since 1.32.0 | |
| 801 | + * | |
| 802 | + * @param string $context_type Context type | |
| 803 | + * @param int|null $context_id Context ID | |
| 804 | + * @param array $expected_types Schema types that were just deployed | |
| 805 | + * @return array Validation result | |
| 806 | + */ | |
| 807 | + private function verify_deployment(string $context_type, ?int $context_id, array $expected_types): array { | |
| 808 | + if (empty($expected_types)) { | |
| 809 | + return [ | |
| 810 | + 'validation_passed' => false, | |
| 811 | + 'message' => 'No schema was deployed', | |
| 812 | + 'missing_types' => [] | |
| 813 | + ]; | |
| 814 | + } | |
| 815 | + | |
| 816 | + $retrieved = $this->get_deployed_schemas($context_type, $context_id); | |
| 817 | + $missing = array_values(array_diff($expected_types, array_keys($retrieved))); | |
| 818 | + | |
| 819 | + if (!empty($missing)) { | |
| 820 | + return [ | |
| 821 | + 'validation_passed' => false, | |
| 822 | + 'message' => sprintf( | |
| 823 | + /* translators: %s: comma-separated list of schema types */ | |
| 824 | + __('Deployed schema could not be read back: %s', 'thinkrank'), | |
| 825 | + implode(', ', $missing) | |
| 826 | + ), | |
| 827 | + 'missing_types' => $missing | |
| 828 | + ]; | |
| 829 | + } | |
| 830 | + | |
| 831 | + return [ | |
| 832 | + 'validation_passed' => true, | |
| 833 | + 'message' => __('Schema deployed and read back from storage', 'thinkrank'), | |
| 834 | + 'missing_types' => [] | |
| 835 | + ]; | |
| 836 | + } | |
| 837 | + | |
| 838 | + /** | |
| 605 | 839 | * Track schema performance and rich snippet appearances |
| 606 | 840 | * |
| 607 | 841 | * @since 1.0.0 |
| 608 | 842 | * |
| @@ -717,9 +951,11 @@ | ||
| 717 | 951 | 'validation_results' => [], |
| 718 | 952 | 'rich_snippets_preview' => [], |
| 719 | 953 | 'performance_data' => [], |
| 720 | 954 | 'recommendations' => [], |
| 721 | - 'enabled' => true | |
| 955 | + // Report the real setting. Hardcoding true here told every consumer | |
| 956 | + // the feature was on even when the master switch was off (#461). | |
| 957 | + 'enabled' => (bool) ($settings['enabled'] ?? true) | |
| 722 | 958 | ]; |
| 723 | 959 | |
| 724 | 960 | // Get enabled schema types |
| 725 | 961 | $enabled_types = $settings['enabled_schema_types'] ?? []; |
| @@ -827,15 +1063,13 @@ | ||
| 827 | 1063 | // For site context: only apply site-level schema settings |
| 828 | 1064 | if ($context_type === 'site') { |
| 829 | 1065 | // Add local business schema if enabled |
| 830 | 1066 | if ($options['enable_local_business'] ?? false) { |
| 831 | - if (!in_array('LocalBusiness', $enabled_types)) { | |
| 1067 | + if (!in_array('LocalBusiness', $enabled_types, true)) { | |
| 832 | 1068 | $enabled_types[] = 'LocalBusiness'; |
| 833 | 1069 | } |
| 834 | 1070 | } |
| 835 | 1071 | |
| 836 | - | |
| 837 | - | |
| 838 | 1072 | return $enabled_types; |
| 839 | 1073 | } |
| 840 | 1074 | |
| 841 | 1075 | // For post/page context: apply all schema settings (metabox functionality) |
| @@ -841,9 +1075,9 @@ | ||
| 841 | 1075 | // For post/page context: apply all schema settings (metabox functionality) |
| 842 | 1076 | |
| 843 | 1077 | // Add article schema if enabled and context is appropriate |
| 844 | 1078 | if ($options['enable_article_schema'] ?? false) { |
| 845 | - if (in_array($context_type, ['post', 'page']) && !in_array('Article', $enabled_types)) { | |
| 1079 | + if (in_array($context_type, ['post', 'page'], true) && !in_array('Article', $enabled_types, true)) { | |
| 846 | 1080 | $enabled_types[] = 'Article'; |
| 847 | 1081 | } |
| 848 | 1082 | } |
| 849 | 1083 | |
| @@ -848,9 +1082,9 @@ | ||
| 848 | 1082 | } |
| 849 | 1083 | |
| 850 | 1084 | // Add FAQ schema if enabled |
| 851 | 1085 | if ($options['enable_faq_schema'] ?? false) { |
| 852 | - if (!in_array('FAQPage', $enabled_types)) { | |
| 1086 | + if (!in_array('FAQPage', $enabled_types, true)) { | |
| 853 | 1087 | $enabled_types[] = 'FAQPage'; |
| 854 | 1088 | } |
| 855 | 1089 | } |
| 856 | 1090 | |
| @@ -855,9 +1089,9 @@ | ||
| 855 | 1089 | } |
| 856 | 1090 | |
| 857 | 1091 | // Add How-To schema if enabled |
| 858 | 1092 | if ($options['enable_howto_schema'] ?? false) { |
| 859 | - if (!in_array('HowTo', $enabled_types)) { | |
| 1093 | + if (!in_array('HowTo', $enabled_types, true)) { | |
| 860 | 1094 | $enabled_types[] = 'HowTo'; |
| 861 | 1095 | } |
| 862 | 1096 | } |
| 863 | 1097 | |
| @@ -862,9 +1096,9 @@ | ||
| 862 | 1096 | } |
| 863 | 1097 | |
| 864 | 1098 | // Add product schema if enabled and context is appropriate |
| 865 | 1099 | if ($options['enable_product_schema'] ?? false) { |
| 866 | - if ($context_type === 'product' && !in_array('Product', $enabled_types)) { | |
| 1100 | + if ($context_type === 'product' && !in_array('Product', $enabled_types, true)) { | |
| 867 | 1101 | $enabled_types[] = 'Product'; |
| 868 | 1102 | } |
| 869 | 1103 | } |
| 870 | 1104 | |
| @@ -869,9 +1103,9 @@ | ||
| 869 | 1103 | } |
| 870 | 1104 | |
| 871 | 1105 | // Add local business schema if enabled |
| 872 | 1106 | if ($options['enable_local_business'] ?? false) { |
| 873 | - if (!in_array('LocalBusiness', $enabled_types)) { | |
| 1107 | + if (!in_array('LocalBusiness', $enabled_types, true)) { | |
| 874 | 1108 | $enabled_types[] = 'LocalBusiness'; |
| 875 | 1109 | } |
| 876 | 1110 | } |
| 877 | 1111 | |
| @@ -892,9 +1126,9 @@ | ||
| 892 | 1126 | // For site context: only apply site-level schema settings |
| 893 | 1127 | if ($context_type === 'site') { |
| 894 | 1128 | // Add local business schema if enabled |
| 895 | 1129 | if ($settings['enable_local_business'] ?? false) { |
| 896 | - if (!in_array('LocalBusiness', $enabled_types)) { | |
| 1130 | + if (!in_array('LocalBusiness', $enabled_types, true)) { | |
| 897 | 1131 | $enabled_types[] = 'LocalBusiness'; |
| 898 | 1132 | } |
| 899 | 1133 | } |
| 900 | 1134 | |
| @@ -899,9 +1133,9 @@ | ||
| 899 | 1133 | } |
| 900 | 1134 | |
| 901 | 1135 | // Add breadcrumbs schema if enabled (site-wide feature) |
| 902 | 1136 | if ($settings['enable_breadcrumbs_schema'] ?? false) { |
| 903 | - if (!in_array('BreadcrumbList', $enabled_types)) { | |
| 1137 | + if (!in_array('BreadcrumbList', $enabled_types, true)) { | |
| 904 | 1138 | $enabled_types[] = 'BreadcrumbList'; |
| 905 | 1139 | } |
| 906 | 1140 | } |
| 907 | 1141 | |
| @@ -911,9 +1145,9 @@ | ||
| 911 | 1145 | // For post/page context: apply all schema settings (metabox functionality) |
| 912 | 1146 | |
| 913 | 1147 | // Add article schema if enabled and context is appropriate |
| 914 | 1148 | if ($settings['enable_article_schema'] ?? false) { |
| 915 | - if (in_array($context_type, ['post', 'page']) && !in_array('Article', $enabled_types)) { | |
| 1149 | + if (in_array($context_type, ['post', 'page'], true) && !in_array('Article', $enabled_types, true)) { | |
| 916 | 1150 | $enabled_types[] = 'Article'; |
| 917 | 1151 | } |
| 918 | 1152 | } |
| 919 | 1153 | |
| @@ -918,9 +1152,9 @@ | ||
| 918 | 1152 | } |
| 919 | 1153 | |
| 920 | 1154 | // Add FAQ schema if enabled |
| 921 | 1155 | if ($settings['enable_faq_schema'] ?? false) { |
| 922 | - if (!in_array('FAQPage', $enabled_types)) { | |
| 1156 | + if (!in_array('FAQPage', $enabled_types, true)) { | |
| 923 | 1157 | $enabled_types[] = 'FAQPage'; |
| 924 | 1158 | } |
| 925 | 1159 | } |
| 926 | 1160 | |
| @@ -925,9 +1159,9 @@ | ||
| 925 | 1159 | } |
| 926 | 1160 | |
| 927 | 1161 | // Add How-To schema if enabled |
| 928 | 1162 | if ($settings['enable_howto_schema'] ?? false) { |
| 929 | - if (!in_array('HowTo', $enabled_types)) { | |
| 1163 | + if (!in_array('HowTo', $enabled_types, true)) { | |
| 930 | 1164 | $enabled_types[] = 'HowTo'; |
| 931 | 1165 | } |
| 932 | 1166 | } |
| 933 | 1167 | |
| @@ -932,9 +1166,9 @@ | ||
| 932 | 1166 | } |
| 933 | 1167 | |
| 934 | 1168 | // Add product schema if enabled and context is appropriate |
| 935 | 1169 | if ($settings['enable_product_schema'] ?? false) { |
| 936 | - if ($context_type === 'product' && !in_array('Product', $enabled_types)) { | |
| 1170 | + if ($context_type === 'product' && !in_array('Product', $enabled_types, true)) { | |
| 937 | 1171 | $enabled_types[] = 'Product'; |
| 938 | 1172 | } |
| 939 | 1173 | } |
| 940 | 1174 | |
| @@ -939,9 +1173,9 @@ | ||
| 939 | 1173 | } |
| 940 | 1174 | |
| 941 | 1175 | // Add local business schema if enabled |
| 942 | 1176 | if ($settings['enable_local_business'] ?? false) { |
| 943 | - if (!in_array('LocalBusiness', $enabled_types)) { | |
| 1177 | + if (!in_array('LocalBusiness', $enabled_types, true)) { | |
| 944 | 1178 | $enabled_types[] = 'LocalBusiness'; |
| 945 | 1179 | } |
| 946 | 1180 | } |
| 947 | 1181 | |
| @@ -947,10 +1181,8 @@ | ||
| 947 | 1181 | |
| 948 | 1182 | return $enabled_types; |
| 949 | 1183 | } |
| 950 | 1184 | |
| 951 | - | |
| 952 | - | |
| 953 | 1185 | /** |
| 954 | 1186 | * Get default organization logo for rich snippets |
| 955 | 1187 | * |
| 956 | 1188 | * @since 1.0.0 |
| @@ -977,8 +1209,47 @@ | ||
| 977 | 1209 | return home_url('/wp-content/plugins/thinkrank/assets/images/default-logo.jpg'); |
| 978 | 1210 | } |
| 979 | 1211 | |
| 980 | 1212 | /** |
| 1213 | + * Schema keys outside the shared config defaults. | |
| 1214 | + * | |
| 1215 | + * @since 2.0.1 | |
| 1216 | + * | |
| 1217 | + * @return string[] | |
| 1218 | + */ | |
| 1219 | + protected function additional_setting_keys(): array { | |
| 1220 | + return [ | |
| 1221 | + 'enable_article_schema', 'enable_product_schema', | |
| 1222 | + 'enable_faq_schema', 'enable_howto_schema', | |
| 1223 | + ]; | |
| 1224 | + } | |
| 1225 | + | |
| 1226 | + /** | |
| 1227 | + * Per-entity schema fields are an open set. | |
| 1228 | + * | |
| 1229 | + * Each schema type the UI can edit contributes its own field family — | |
| 1230 | + * organization_*, person_*, website_*, business_*, software_*, howto_* — | |
| 1231 | + * and a new type adds another. The families this manager owns are matched | |
| 1232 | + * rather than enumerated, so adding a form does not silently start | |
| 1233 | + * dropping its fields (#452). | |
| 1234 | + * | |
| 1235 | + * @since 2.0.1 | |
| 1236 | + * | |
| 1237 | + * @return string[] | |
| 1238 | + */ | |
| 1239 | + protected function dynamic_setting_key_patterns(): array { | |
| 1240 | + return [ | |
| 1241 | + '/^organization_[a-z0-9_]+$/', | |
| 1242 | + '/^person_[a-z0-9_]+$/', | |
| 1243 | + '/^website_[a-z0-9_]+$/', | |
| 1244 | + '/^business_[a-z0-9_]+$/', | |
| 1245 | + '/^software_[a-z0-9_]+$/', | |
| 1246 | + '/^howto_[a-z0-9_]+$/', | |
| 1247 | + '/^product_[a-z0-9_]+$/', | |
| 1248 | + ]; | |
| 1249 | + } | |
| 1250 | + | |
| 1251 | + /** | |
| 981 | 1252 | * Get default settings for a context type (implements interface) |
| 982 | 1253 | * |
| 983 | 1254 | * @since 1.0.0 |
| 984 | 1255 | * |
| @@ -1001,12 +1272,13 @@ | ||
| 1001 | 1272 | return Schema_Settings_Config::get_settings_schema($context_type); |
| 1002 | 1273 | } |
| 1003 | 1274 | |
| 1004 | 1275 | /** |
| 1005 | - * Save SEO settings with cache invalidation | |
| 1276 | + * Save SEO settings with cache invalidation and auto-deployment | |
| 1006 | 1277 | * |
| 1007 | 1278 | * Overrides parent method to add schema cache invalidation when settings change. |
| 1008 | 1279 | * This ensures cached schema data is refreshed when configuration changes. |
| 1280 | + * Also triggers auto-deployment of schema when enabled. | |
| 1009 | 1281 | * |
| 1010 | 1282 | * @since 1.0.0 |
| 1011 | 1283 | * |
| 1012 | 1284 | * @param string $context_type The context type |
| @@ -1022,12 +1294,215 @@ | ||
| 1022 | 1294 | if ($success && $this->cache_manager) { |
| 1023 | 1295 | $this->cache_manager->invalidate_all_cache(); |
| 1024 | 1296 | } |
| 1025 | 1297 | |
| 1298 | + // AUTO-DEPLOY: Automatically regenerate and deploy schema when settings change | |
| 1299 | + if ($success && !empty($settings['auto_deploy'])) { | |
| 1300 | + $this->auto_deploy_schema_on_settings_change($context_type, $context_id, $settings); | |
| 1301 | + } | |
| 1302 | + | |
| 1026 | 1303 | return $success; |
| 1027 | 1304 | } |
| 1028 | 1305 | |
| 1029 | 1306 | /** |
| 1307 | + * Auto-deploy schema when settings change | |
| 1308 | + * | |
| 1309 | + * Automatically regenerates and deploys schema markup when organization or other | |
| 1310 | + * schema settings are modified, ensuring the frontend output stays in sync. | |
| 1311 | + * | |
| 1312 | + * @since 1.0.0 | |
| 1313 | + * | |
| 1314 | + * @param string $context_type Context type | |
| 1315 | + * @param int|null $context_id Context ID | |
| 1316 | + * @param array $settings Updated settings | |
| 1317 | + * @return void | |
| 1318 | + */ | |
| 1319 | + private function auto_deploy_schema_on_settings_change(string $context_type, ?int $context_id, array $settings): void { | |
| 1320 | + // Determine which schema types need to be regenerated based on changed settings | |
| 1321 | + $schema_types_to_regenerate = []; | |
| 1322 | + | |
| 1323 | + // Organization schema - regenerate if organization settings changed | |
| 1324 | + if ($this->has_organization_settings_changed($settings)) { | |
| 1325 | + $schema_types_to_regenerate[] = 'Organization'; | |
| 1326 | + } | |
| 1327 | + | |
| 1328 | + // Website schema - regenerate if website settings changed. The type is | |
| 1329 | + // registered as 'WebSite' (capital S) in Schema_Factory / $schema_types; | |
| 1330 | + // using 'Website' here made generate_schema_markup() silently skip it. | |
| 1331 | + if ($this->has_website_settings_changed($settings)) { | |
| 1332 | + $schema_types_to_regenerate[] = 'WebSite'; | |
| 1333 | + } | |
| 1334 | + | |
| 1335 | + // LocalBusiness schema - regenerate if business settings changed | |
| 1336 | + if ($this->has_business_settings_changed($settings)) { | |
| 1337 | + $schema_types_to_regenerate[] = 'LocalBusiness'; | |
| 1338 | + } | |
| 1339 | + | |
| 1340 | + // Person schema - regenerate if person settings changed | |
| 1341 | + if ($this->has_person_settings_changed($settings)) { | |
| 1342 | + $schema_types_to_regenerate[] = 'Person'; | |
| 1343 | + } | |
| 1344 | + | |
| 1345 | + // Honour the user's Schema Types selection. Without this the payload | |
| 1346 | + // shape alone decided what shipped, so every save deployed all four | |
| 1347 | + // types — including ones the user had explicitly deselected (#461). | |
| 1348 | + // An empty selection means "auto", so only filter when one is set. | |
| 1349 | + $enabled_types = $settings['enabled_schema_types'] ?? $this->get_settings($context_type, $context_id)['enabled_schema_types'] ?? []; | |
| 1350 | + | |
| 1351 | + if (!empty($enabled_types) && is_array($enabled_types)) { | |
| 1352 | + $schema_types_to_regenerate = array_values( | |
| 1353 | + array_intersect($schema_types_to_regenerate, $enabled_types) | |
| 1354 | + ); | |
| 1355 | + } | |
| 1356 | + | |
| 1357 | + // Types that were deployed but are no longer wanted must come back off | |
| 1358 | + // the page — deployment used to be additive-only (#464). | |
| 1359 | + $this->retire_unselected_schema_types($context_type, $context_id, $enabled_types); | |
| 1360 | + | |
| 1361 | + // If no schema types need regeneration, return early | |
| 1362 | + if (empty($schema_types_to_regenerate)) { | |
| 1363 | + return; | |
| 1364 | + } | |
| 1365 | + | |
| 1366 | + // Generate every affected type in ONE call. Generating them one at a | |
| 1367 | + // time re-entered store_schema_data() per type, and each pass replaced | |
| 1368 | + // the rows written by the previous one, so only the last type survived | |
| 1369 | + // (#454). One batch also means one delete and one cache flush. | |
| 1370 | + try { | |
| 1371 | + $generation_result = $this->generate_schema_markup( | |
| 1372 | + $context_type, | |
| 1373 | + $context_id, | |
| 1374 | + $schema_types_to_regenerate | |
| 1375 | + ); | |
| 1376 | + | |
| 1377 | + $deployable = []; | |
| 1378 | + foreach ($schema_types_to_regenerate as $schema_type) { | |
| 1379 | + // Only deploy what validated — see #470. | |
| 1380 | + if (!empty($generation_result['generated_schemas'][$schema_type]) | |
| 1381 | + && !empty($generation_result['validation_results'][$schema_type]['is_valid']) | |
| 1382 | + ) { | |
| 1383 | + $deployable[$schema_type] = $generation_result['generated_schemas'][$schema_type]; | |
| 1384 | + } | |
| 1385 | + } | |
| 1386 | + | |
| 1387 | + if (!empty($deployable)) { | |
| 1388 | + $this->deploy_schema_markup($context_type, $context_id, $deployable); | |
| 1389 | + } | |
| 1390 | + } catch (\Exception $e) { | |
| 1391 | + // Log error but don't fail the settings save | |
| 1392 | + if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { | |
| 1393 | + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log | |
| 1394 | + error_log('ThinkRank: Auto-deploy failed for ' . implode(', ', $schema_types_to_regenerate) . ': ' . $e->getMessage()); | |
| 1395 | + } | |
| 1396 | + } | |
| 1397 | + } | |
| 1398 | + | |
| 1399 | + /** | |
| 1400 | + * Check if organization settings have changed | |
| 1401 | + * | |
| 1402 | + * @since 1.0.0 | |
| 1403 | + * | |
| 1404 | + * @param array $settings Updated settings | |
| 1405 | + * @return bool True if organization settings changed | |
| 1406 | + */ | |
| 1407 | + private function has_organization_settings_changed(array $settings): bool { | |
| 1408 | + $org_keys = [ | |
| 1409 | + 'organization_name', 'organization_type', 'organization_logo', 'organization_url', | |
| 1410 | + 'organization_description', 'organization_social_facebook', 'organization_social_twitter', | |
| 1411 | + 'organization_social_linkedin', 'organization_social_instagram', 'organization_social_youtube', | |
| 1412 | + 'organization_social_pinterest', 'organization_social_whatsapp', 'organization_social_telegram', | |
| 1413 | + 'organization_contact_type', 'organization_contact_phone', 'organization_contact_email', | |
| 1414 | + 'organization_contact_hours' | |
| 1415 | + ]; | |
| 1416 | + | |
| 1417 | + foreach ($org_keys as $key) { | |
| 1418 | + if (isset($settings[$key])) { | |
| 1419 | + return true; | |
| 1420 | + } | |
| 1421 | + } | |
| 1422 | + | |
| 1423 | + return false; | |
| 1424 | + } | |
| 1425 | + | |
| 1426 | + /** | |
| 1427 | + * Check if website settings have changed | |
| 1428 | + * | |
| 1429 | + * @since 1.0.0 | |
| 1430 | + * | |
| 1431 | + * @param array $settings Updated settings | |
| 1432 | + * @return bool True if website settings changed | |
| 1433 | + */ | |
| 1434 | + private function has_website_settings_changed(array $settings): bool { | |
| 1435 | + // These are the keys the Website tab actually stores. It previously | |
| 1436 | + // looked for site_name/site_description/site_url, which belong to Site | |
| 1437 | + // Identity and never appear in a schema settings payload — so WebSite | |
| 1438 | + // schema never auto-deployed no matter what was edited (#455). | |
| 1439 | + $website_keys = [ | |
| 1440 | + 'website_name', 'website_url', 'website_description', 'website_author', | |
| 1441 | + ]; | |
| 1442 | + | |
| 1443 | + foreach ($website_keys as $key) { | |
| 1444 | + if (isset($settings[$key])) { | |
| 1445 | + return true; | |
| 1446 | + } | |
| 1447 | + } | |
| 1448 | + | |
| 1449 | + return false; | |
| 1450 | + } | |
| 1451 | + | |
| 1452 | + /** | |
| 1453 | + * Check if business settings have changed | |
| 1454 | + * | |
| 1455 | + * @since 1.0.0 | |
| 1456 | + * | |
| 1457 | + * @param array $settings Updated settings | |
| 1458 | + * @return bool True if business settings changed | |
| 1459 | + */ | |
| 1460 | + private function has_business_settings_changed(array $settings): bool { | |
| 1461 | + // Only the keys this manager actually stores. business_name/address/ | |
| 1462 | + // phone/hours live in the site_identity category and never reach a | |
| 1463 | + // schema settings save, so keying off them meant LocalBusiness never | |
| 1464 | + // auto-deployed (#455). Edits to those fields refresh LocalBusiness | |
| 1465 | + // through the Site Identity save path instead — see | |
| 1466 | + // refresh_schema_for_foreign_settings(). | |
| 1467 | + $business_keys = [ | |
| 1468 | + 'enable_local_business', | |
| 1469 | + 'business_price_range', | |
| 1470 | + 'business_geo_latitude', | |
| 1471 | + 'business_geo_longitude', | |
| 1472 | + 'business_opening_hours', | |
| 1473 | + ]; | |
| 1474 | + | |
| 1475 | + foreach ($business_keys as $key) { | |
| 1476 | + if (isset($settings[$key])) { | |
| 1477 | + return true; | |
| 1478 | + } | |
| 1479 | + } | |
| 1480 | + | |
| 1481 | + return false; | |
| 1482 | + } | |
| 1483 | + | |
| 1484 | + /** | |
| 1485 | + * Check if person settings have changed | |
| 1486 | + * | |
| 1487 | + * @since 1.0.0 | |
| 1488 | + * | |
| 1489 | + * @param array $settings Updated settings | |
| 1490 | + * @return bool True if person settings changed | |
| 1491 | + */ | |
| 1492 | + private function has_person_settings_changed(array $settings): bool { | |
| 1493 | + $person_keys = ['person_name', 'person_image', 'person_job_title', 'person_description']; | |
| 1494 | + | |
| 1495 | + foreach ($person_keys as $key) { | |
| 1496 | + if (isset($settings[$key])) { | |
| 1497 | + return true; | |
| 1498 | + } | |
| 1499 | + } | |
| 1500 | + | |
| 1501 | + return false; | |
| 1502 | + } | |
| 1503 | + | |
| 1504 | + /** | |
| 1030 | 1505 | * Auto-detect appropriate schema types for context |
| 1031 | 1506 | * |
| 1032 | 1507 | * @since 1.0.0 |
| 1033 | 1508 | * |
| @@ -1039,8 +1514,29 @@ | ||
| 1039 | 1514 | $detected_types = []; |
| 1040 | 1515 | |
| 1041 | 1516 | switch ($context_type) { |
| 1042 | 1517 | case 'site': |
| 1518 | + // The admin's Schema Types selection is the answer to "what | |
| 1519 | + // does this site need"; detection is only the fallback for an | |
| 1520 | + // install that has not chosen yet (#456). | |
| 1521 | + $settings = $this->get_settings($context_type, $context_id); | |
| 1522 | + $enabled = array_values(array_filter( | |
| 1523 | + array_map('strval', (array) ($settings['enabled_schema_types'] ?? [])), | |
| 1524 | + 'strlen' | |
| 1525 | + )); | |
| 1526 | + | |
| 1527 | + // Drop stale names the factory no longer registers rather than | |
| 1528 | + // handing them to the builder to silently skip. | |
| 1529 | + $enabled = array_values(array_filter( | |
| 1530 | + $enabled, | |
| 1531 | + fn($type) => isset($this->schema_types[$type]) | |
| 1532 | + )); | |
| 1533 | + | |
| 1534 | + if (!empty($enabled)) { | |
| 1535 | + $detected_types = $enabled; | |
| 1536 | + break; | |
| 1537 | + } | |
| 1538 | + | |
| 1043 | 1539 | $detected_types = ['Organization']; |
| 1044 | 1540 | // Check if it's a local business |
| 1045 | 1541 | if ($this->is_local_business()) { |
| 1046 | 1542 | $detected_types[] = 'LocalBusiness'; |
| @@ -1050,11 +1546,8 @@ | ||
| 1050 | 1546 | $detected_types = ['Article']; |
| 1051 | 1547 | // Check content type for specific article types |
| 1052 | 1548 | if ($context_id) { |
| 1053 | 1549 | $post = get_post($context_id); |
| 1054 | - if ($post && $this->is_recipe_content($post->post_content)) { | |
| 1055 | - $detected_types[] = 'Recipe'; | |
| 1056 | - } | |
| 1057 | 1550 | if ($post && $this->is_how_to_content($post->post_content)) { |
| 1058 | 1551 | $detected_types[] = 'HowTo'; |
| 1059 | 1552 | } |
| 1060 | 1553 | } |
| @@ -1063,9 +1556,9 @@ | ||
| 1063 | 1556 | $detected_types = ['Article']; |
| 1064 | 1557 | if ($context_id) { |
| 1065 | 1558 | $page = get_post($context_id); |
| 1066 | 1559 | if ($page && $this->is_faq_content($page->post_content)) { |
| 1067 | - $detected_types[] = 'FAQ'; | |
| 1560 | + $detected_types[] = 'FAQPage'; | |
| 1068 | 1561 | } |
| 1069 | 1562 | } |
| 1070 | 1563 | break; |
| 1071 | 1564 | case 'product': |
| @@ -1102,9 +1595,9 @@ | ||
| 1102 | 1595 | 'business_data' => $this->get_business_data_from_local_seo(), |
| 1103 | 1596 | 'site_data' => $this->get_site_data_for_schema(), |
| 1104 | 1597 | 'social_data' => $this->get_social_data_for_schema() |
| 1105 | 1598 | ]; |
| 1106 | - } elseif ($context_id && in_array($context_type, ['post', 'page', 'product'])) { | |
| 1599 | + } elseif ($context_id && in_array($context_type, ['post', 'page', 'product'], true)) { | |
| 1107 | 1600 | // Post/page/product data |
| 1108 | 1601 | $post = get_post($context_id); |
| 1109 | 1602 | if ($post) { |
| 1110 | 1603 | $content_data = [ |
| @@ -1109,17 +1602,22 @@ | ||
| 1109 | 1602 | if ($post) { |
| 1110 | 1603 | $content_data = [ |
| 1111 | 1604 | 'title' => $post->post_title, |
| 1112 | 1605 | 'url' => get_permalink($post->ID), |
| 1113 | - 'excerpt' => $post->post_excerpt ?: wp_trim_words($post->post_content, 30), | |
| 1606 | + 'excerpt' => $post->post_excerpt ?: \ThinkRank\Core\Seo_Text::trim_words($post->post_content, 30), | |
| 1114 | 1607 | 'content' => $post->post_content, |
| 1115 | 1608 | 'author' => [ |
| 1116 | 1609 | 'name' => get_the_author_meta('display_name', $post->post_author), |
| 1117 | 1610 | 'url' => get_author_posts_url($post->post_author) |
| 1118 | 1611 | ], |
| 1119 | - 'date' => $post->post_date, | |
| 1120 | - 'modified' => $post->post_modified, | |
| 1612 | + // ISO 8601 with offset. post_date/post_modified are raw | |
| 1613 | + // MySQL columns in site-local time with no timezone, which | |
| 1614 | + // Google rejects as "Invalid value in field datePublished" | |
| 1615 | + // and drops the Article rich result (#465). | |
| 1616 | + 'date' => get_the_date('c', $post), | |
| 1617 | + 'modified' => get_the_modified_date('c', $post), | |
| 1121 | 1618 | 'image' => get_the_post_thumbnail_url($post->ID, 'full'), |
| 1619 | + 'focus_keywords' => Focus_Keywords::get($post->ID), | |
| 1122 | 1620 | 'business_data' => $this->get_business_data_from_local_seo(), |
| 1123 | 1621 | 'site_data' => $this->get_site_data_for_schema(), |
| 1124 | 1622 | 'social_data' => $this->get_social_data_for_schema() |
| 1125 | 1623 | ]; |
| @@ -1152,9 +1650,12 @@ | ||
| 1152 | 1650 | $content_data['url'] = $custom_data['post_url']; |
| 1153 | 1651 | } |
| 1154 | 1652 | } |
| 1155 | 1653 | |
| 1156 | - // Add focus keyword if provided | |
| 1654 | + // Add focus keyword(s) if provided | |
| 1655 | + if (!empty($custom_data['focus_keywords']) && is_array($custom_data['focus_keywords'])) { | |
| 1656 | + $content_data['focus_keywords'] = $custom_data['focus_keywords']; | |
| 1657 | + } | |
| 1157 | 1658 | if (!empty($custom_data['focus_keyword'])) { |
| 1158 | 1659 | $content_data['focus_keyword'] = $custom_data['focus_keyword']; |
| 1159 | 1660 | } |
| 1160 | 1661 | |
| @@ -1194,12 +1695,8 @@ | ||
| 1194 | 1695 | |
| 1195 | 1696 | return $content_data; |
| 1196 | 1697 | } |
| 1197 | 1698 | |
| 1198 | - | |
| 1199 | - | |
| 1200 | - | |
| 1201 | - | |
| 1202 | 1699 | /** |
| 1203 | 1700 | * Store schema data in database |
| 1204 | 1701 | * |
| 1205 | 1702 | * @since 1.0.0 |
| @@ -1213,10 +1710,16 @@ | ||
| 1213 | 1710 | global $wpdb; |
| 1214 | 1711 | |
| 1215 | 1712 | $table_name = $wpdb->prefix . 'thinkrank_seo_schema'; |
| 1216 | 1713 | |
| 1217 | - // First, delete all existing schemas for this context to ensure clean storage | |
| 1218 | - $this->delete_existing_schemas($context_type, $context_id); | |
| 1714 | + // Replace only the types in this batch. Clearing the whole context | |
| 1715 | + // destroyed types the caller never asked about — and callers do | |
| 1716 | + // regenerate a subset, one type at a time (#454). | |
| 1717 | + $generated_types = array_keys($generation['generated_schemas'] ?? []); | |
| 1718 | + if (empty($generated_types)) { | |
| 1719 | + return false; | |
| 1720 | + } | |
| 1721 | + $this->delete_existing_schemas($context_type, $context_id, $generated_types); | |
| 1219 | 1722 | |
| 1220 | 1723 | foreach ($generation['generated_schemas'] as $schema_type => $schema_data) { |
| 1221 | 1724 | // Prepare schema data with validation status embedded |
| 1222 | 1725 | $schema_data_with_validation = $schema_data; |
| @@ -1232,13 +1735,17 @@ | ||
| 1232 | 1735 | 'context_id' => $context_id, |
| 1233 | 1736 | 'schema_type' => $schema_type, |
| 1234 | 1737 | 'schema_data' => wp_json_encode($schema_data_with_validation), |
| 1235 | 1738 | 'validation_status' => $generation['validation_results'][$schema_type]['is_valid'] ? 'valid' : 'invalid', |
| 1236 | - 'is_active' => $generation['deployment_ready'] ? 1 : 0 | |
| 1739 | + // Per-type, not batch-wide. deployment_ready is only true when | |
| 1740 | + // EVERY type in the batch validated, so one invalid type (a site | |
| 1741 | + // with no Business Info makes LocalBusiness invalid) deactivated | |
| 1742 | + // all the valid ones alongside it (#470). | |
| 1743 | + 'is_active' => !empty($generation['validation_results'][$schema_type]['is_valid']) ? 1 : 0 | |
| 1237 | 1744 | ]; |
| 1238 | 1745 | |
| 1239 | 1746 | // Insert new schema (existing ones were already deleted) |
| 1240 | - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Schema insertion requires direct database access | |
| 1747 | + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema insertion requires direct database access | |
| 1241 | 1748 | $wpdb->insert($table_name, $data); |
| 1242 | 1749 | } |
| 1243 | 1750 | |
| 1244 | 1751 | // CACHE INVALIDATION: Clear cache after storing new schema data |
| @@ -1272,60 +1779,8 @@ | ||
| 1272 | 1779 | */ |
| 1273 | 1780 | |
| 1274 | 1781 | // Removed complex AI integration methods - moved to separate services |
| 1275 | 1782 | // Schema management focuses on core structured data generation |
| 1276 | - | |
| 1277 | - private function extract_content_for_schema(string $context_type, ?int $context_id): string { | |
| 1278 | - $content = ''; | |
| 1279 | - | |
| 1280 | - switch ($context_type) { | |
| 1281 | - case 'post': | |
| 1282 | - case 'page': | |
| 1283 | - case 'product': | |
| 1284 | - if ($context_id) { | |
| 1285 | - $post = get_post($context_id); | |
| 1286 | - if ($post) { | |
| 1287 | - $content = $post->post_title . ' ' . $post->post_content; | |
| 1288 | - } | |
| 1289 | - } | |
| 1290 | - break; | |
| 1291 | - case 'site': | |
| 1292 | - $content = get_bloginfo('name') . ' ' . get_bloginfo('description'); | |
| 1293 | - break; | |
| 1294 | - } | |
| 1295 | - | |
| 1296 | - return $content; | |
| 1297 | - } | |
| 1298 | - | |
| 1299 | - private function extract_keywords_for_schema(string $context_type, ?int $context_id): array { | |
| 1300 | - // Simple keyword extraction - would be enhanced with actual keyword data | |
| 1301 | - $content = $this->extract_content_for_schema($context_type, $context_id); | |
| 1302 | - if (!empty($content)) { | |
| 1303 | - $words = str_word_count(strtolower(wp_strip_all_tags($content)), 1); | |
| 1304 | - $word_counts = array_count_values($words); | |
| 1305 | - arsort($word_counts); | |
| 1306 | - return array_slice(array_keys($word_counts), 0, 3); | |
| 1307 | - } | |
| 1308 | - | |
| 1309 | - return []; | |
| 1310 | - } | |
| 1311 | - | |
| 1312 | - private function determine_content_type(string $context_type): string { | |
| 1313 | - switch ($context_type) { | |
| 1314 | - case 'post': | |
| 1315 | - return 'blog_post'; | |
| 1316 | - case 'page': | |
| 1317 | - return 'landing_page'; | |
| 1318 | - case 'product': | |
| 1319 | - return 'product_page'; | |
| 1320 | - default: | |
| 1321 | - return 'blog_post'; | |
| 1322 | - } | |
| 1323 | - } | |
| 1324 | - | |
| 1325 | - // Removed duplicate validate_schema method - use validate_schema_markup instead | |
| 1326 | - // which properly uses Schema_Validator for comprehensive validation | |
| 1327 | - | |
| 1328 | 1783 | private function generate_rich_snippets_preview(array $schema_data, string $schema_type): array { |
| 1329 | 1784 | return [ |
| 1330 | 1785 | 'preview_type' => $schema_type, |
| 1331 | 1786 | 'title' => $schema_data['headline'] ?? $schema_data['name'] ?? 'Title', |
| @@ -1434,22 +1889,8 @@ | ||
| 1434 | 1889 | |
| 1435 | 1890 | return false; |
| 1436 | 1891 | } |
| 1437 | 1892 | |
| 1438 | - private function is_recipe_content(string $content): bool { | |
| 1439 | - $recipe_keywords = ['ingredients', 'instructions', 'recipe', 'cooking', 'bake', 'cook']; | |
| 1440 | - $content_lower = strtolower($content); | |
| 1441 | - | |
| 1442 | - $matches = 0; | |
| 1443 | - foreach ($recipe_keywords as $keyword) { | |
| 1444 | - if (stripos($content_lower, $keyword) !== false) { | |
| 1445 | - $matches++; | |
| 1446 | - } | |
| 1447 | - } | |
| 1448 | - | |
| 1449 | - return $matches >= 2; | |
| 1450 | - } | |
| 1451 | - | |
| 1452 | 1893 | private function is_how_to_content(string $content): bool { |
| 1453 | 1894 | $how_to_keywords = ['step', 'how to', 'tutorial', 'guide', 'instructions']; |
| 1454 | 1895 | $content_lower = strtolower($content); |
| 1455 | 1896 | |
| @@ -1474,10 +1915,8 @@ | ||
| 1474 | 1915 | |
| 1475 | 1916 | return false; |
| 1476 | 1917 | } |
| 1477 | 1918 | |
| 1478 | - | |
| 1479 | - | |
| 1480 | 1919 | private function determine_deployment_method(array $options): string { |
| 1481 | 1920 | // Always use JSON-LD as it's the only supported method |
| 1482 | 1921 | return 'json_ld'; |
| 1483 | 1922 | } |
| @@ -1499,17 +1938,17 @@ | ||
| 1499 | 1938 | |
| 1500 | 1939 | // Check if schema already exists for this context and type |
| 1501 | 1940 | if (null === $context_id) { |
| 1502 | 1941 | // Handle NULL context_id case |
| 1503 | - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Schema deployment requires direct database access, table name is validated | |
| 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 | |
| 1504 | 1943 | $sql = sprintf( |
| 1505 | 1944 | 'SELECT schema_id FROM %s WHERE context_type = %%s AND context_id IS NULL AND schema_type = %%s', |
| 1506 | 1945 | $table_name |
| 1507 | 1946 | ); |
| 1508 | - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Schema deployment requires direct database access | |
| 1947 | + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema deployment requires direct database access | |
| 1509 | 1948 | $existing = $wpdb->get_var( |
| 1510 | 1949 | $wpdb->prepare( |
| 1511 | - // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- SQL is properly prepared with placeholders | |
| 1950 | + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders | |
| 1512 | 1951 | $sql, |
| 1513 | 1952 | $context_type, |
| 1514 | 1953 | $schema_type |
| 1515 | 1954 | ) |
| @@ -1515,17 +1954,17 @@ | ||
| 1515 | 1954 | ) |
| 1516 | 1955 | ); |
| 1517 | 1956 | } else { |
| 1518 | 1957 | // Handle regular context_id case |
| 1519 | - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Schema deployment requires direct database access, table name is validated | |
| 1958 | + // 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 | |
| 1520 | 1959 | $sql = sprintf( |
| 1521 | 1960 | 'SELECT schema_id FROM %s WHERE context_type = %%s AND context_id = %%d AND schema_type = %%s', |
| 1522 | 1961 | $table_name |
| 1523 | 1962 | ); |
| 1524 | - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Schema deployment requires direct database access | |
| 1963 | + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema deployment requires direct database access | |
| 1525 | 1964 | $existing = $wpdb->get_var( |
| 1526 | 1965 | $wpdb->prepare( |
| 1527 | - // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- SQL is properly prepared with placeholders | |
| 1966 | + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders | |
| 1528 | 1967 | $sql, |
| 1529 | 1968 | $context_type, |
| 1530 | 1969 | $context_id, |
| 1531 | 1970 | $schema_type |
| @@ -1534,9 +1973,9 @@ | ||
| 1534 | 1973 | } |
| 1535 | 1974 | |
| 1536 | 1975 | if ($existing) { |
| 1537 | 1976 | // Update existing deployment |
| 1538 | - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Schema update requires direct database access | |
| 1977 | + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema update requires direct database access | |
| 1539 | 1978 | $result = $wpdb->update( |
| 1540 | 1979 | $table_name, |
| 1541 | 1980 | [ |
| 1542 | 1981 | 'schema_data' => wp_json_encode($schema), |
| @@ -1550,9 +1989,9 @@ | ||
| 1550 | 1989 | ); |
| 1551 | 1990 | |
| 1552 | 1991 | } else { |
| 1553 | 1992 | // Insert new deployment |
| 1554 | - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Schema insertion requires direct database access | |
| 1993 | + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema insertion requires direct database access | |
| 1555 | 1994 | $result = $wpdb->insert( |
| 1556 | 1995 | $table_name, |
| 1557 | 1996 | $deployment_data, |
| 1558 | 1997 | ['%s', '%d', '%s', '%s', '%s', '%d'] |
| @@ -1567,10 +2006,8 @@ | ||
| 1567 | 2006 | 'schema_id' => $existing ?: $wpdb->insert_id |
| 1568 | 2007 | ]; |
| 1569 | 2008 | } |
| 1570 | 2009 | |
| 1571 | - | |
| 1572 | - | |
| 1573 | 2010 | /** |
| 1574 | 2011 | * Get deployed schemas for frontend integration |
| 1575 | 2012 | * |
| 1576 | 2013 | * PERFORMANCE OPTIMIZED: This method now uses: |
| @@ -1597,10 +2034,8 @@ | ||
| 1597 | 2034 | } |
| 1598 | 2035 | |
| 1599 | 2036 | global $wpdb; |
| 1600 | 2037 | |
| 1601 | - | |
| 1602 | - | |
| 1603 | 2038 | // Use existing seo_schema table |
| 1604 | 2039 | $table_name = $wpdb->prefix . 'thinkrank_seo_schema'; |
| 1605 | 2040 | |
| 1606 | 2041 | // OPTIMIZED QUERY: Use window function approach to eliminate correlated subquery |
| @@ -1605,17 +2040,17 @@ | ||
| 1605 | 2040 | |
| 1606 | 2041 | // OPTIMIZED QUERY: Use window function approach to eliminate correlated subquery |
| 1607 | 2042 | // This leverages the new composite index: idx_context_schema_active (context_type, schema_type, is_active, created_at DESC) |
| 1608 | 2043 | if (null === $context_id) { |
| 1609 | - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Schema retrieval requires direct database access | |
| 2044 | + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema retrieval requires direct database access | |
| 1610 | 2045 | $sql = sprintf( |
| 1611 | 2046 | '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', |
| 1612 | 2047 | $table_name |
| 1613 | 2048 | ); |
| 1614 | - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Schema retrieval requires direct database access | |
| 2049 | + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema retrieval requires direct database access | |
| 1615 | 2050 | $deployed_schemas = $wpdb->get_results( |
| 1616 | 2051 | $wpdb->prepare( |
| 1617 | - // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- SQL is properly prepared with placeholders | |
| 2052 | + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders | |
| 1618 | 2053 | $sql, |
| 1619 | 2054 | $context_type |
| 1620 | 2055 | ), |
| 1621 | 2056 | ARRAY_A |
| @@ -1620,17 +2055,17 @@ | ||
| 1620 | 2055 | ), |
| 1621 | 2056 | ARRAY_A |
| 1622 | 2057 | ); |
| 1623 | 2058 | } else { |
| 1624 | - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Schema retrieval requires direct database access | |
| 2059 | + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema retrieval requires direct database access | |
| 1625 | 2060 | $sql = sprintf( |
| 1626 | 2061 | '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', |
| 1627 | 2062 | $table_name |
| 1628 | 2063 | ); |
| 1629 | - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Schema retrieval requires direct database access | |
| 2064 | + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema retrieval requires direct database access | |
| 1630 | 2065 | $deployed_schemas = $wpdb->get_results( |
| 1631 | 2066 | $wpdb->prepare( |
| 1632 | - // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- SQL is properly prepared with placeholders | |
| 2067 | + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders | |
| 1633 | 2068 | $sql, |
| 1634 | 2069 | $context_type, |
| 1635 | 2070 | $context_id |
| 1636 | 2071 | ), |
| @@ -1637,13 +2072,15 @@ | ||
| 1637 | 2072 | ARRAY_A |
| 1638 | 2073 | ); |
| 1639 | 2074 | } |
| 1640 | 2075 | |
| 2076 | + // Deliberately no early return on an empty result: it has to reach the | |
| 2077 | + // cache write below. Most URLs have no deployed schema, so gating the | |
| 2078 | + // write on a non-empty result made the majority of front-end requests | |
| 2079 | + // permanent cache misses, re-running a ROW_NUMBER() OVER (PARTITION BY | |
| 2080 | + // ...) query with two filesorts on every pageview (#392). | |
| 2081 | + $deployed_schemas = $deployed_schemas ?: []; | |
| 1641 | 2082 | |
| 1642 | - if (empty($deployed_schemas)) { | |
| 1643 | - return []; | |
| 1644 | - } | |
| 1645 | - | |
| 1646 | 2083 | // Process schemas for return |
| 1647 | 2084 | $processed_schemas = []; |
| 1648 | 2085 | foreach ($deployed_schemas as $deployed_schema) { |
| 1649 | 2086 | $schema_data = json_decode($deployed_schema['schema_data'], true); |
| @@ -1654,8 +2091,26 @@ | ||
| 1654 | 2091 | if (isset($schema_data['_validation'])) { |
| 1655 | 2092 | unset($schema_data['_validation']); |
| 1656 | 2093 | } |
| 1657 | 2094 | |
| 2095 | + // Deployed schema is a snapshot, so rows written before #465 | |
| 2096 | + // still carry raw MySQL datetimes. Normalise on read so the | |
| 2097 | + // fix reaches existing sites without a migration. | |
| 2098 | + $schema_data = $this->normalize_stored_schema($schema_data); | |
| 2099 | + | |
| 2100 | + // The permalink was frozen at deploy time, so schema deployed | |
| 2101 | + // while a post was a draft advertised "?p=123" as both url and | |
| 2102 | + // mainEntityOfPage forever — contradicting the node's own @id | |
| 2103 | + // and the canonical (#470). Resolve it live instead. | |
| 2104 | + $schema_data = $this->refresh_schema_permalink($schema_data, $context_type, $context_id); | |
| 2105 | + | |
| 2106 | + // schema.org types `sameAs`, `url`, `logo` and `image` as URLs, | |
| 2107 | + // but the form stored whatever was typed, so free text entered | |
| 2108 | + // in a social-profile field shipped as a sameAs member and made | |
| 2109 | + // the whole entity invalid (#480). Drop bad values on read, so | |
| 2110 | + // existing sites stop emitting them without a migration. | |
| 2111 | + $schema_data = $this->filter_entity_urls($schema_data); | |
| 2112 | + | |
| 1658 | 2113 | $processed_schemas[$schema_type] = [ |
| 1659 | 2114 | 'data' => $schema_data, |
| 1660 | 2115 | 'method' => 'json_ld', // Default method |
| 1661 | 2116 | 'type' => $schema_type |
| @@ -1662,10 +2117,13 @@ | ||
| 1662 | 2117 | ]; |
| 1663 | 2118 | } |
| 1664 | 2119 | } |
| 1665 | 2120 | |
| 1666 | - // CACHE LAYER: Store result in cache for future requests | |
| 1667 | - if ($this->cache_manager && !empty($processed_schemas)) { | |
| 2121 | + // CACHE LAYER: Store result in cache for future requests — including | |
| 2122 | + // an empty one. Cache_Manager::set() wraps the payload in a metadata | |
| 2123 | + // envelope, so an empty result is still stored as a truthy value and | |
| 2124 | + // reads back as a hit rather than a miss (#392). | |
| 2125 | + if ($this->cache_manager) { | |
| 1668 | 2126 | $cache_key = $this->cache_manager->generate_deployed_schemas_key($context_type, $context_id); |
| 1669 | 2127 | $this->cache_manager->set($cache_key, $processed_schemas); |
| 1670 | 2128 | } |
| 1671 | 2129 | |
| @@ -1672,8 +2130,208 @@ | ||
| 1672 | 2130 | return $processed_schemas; |
| 1673 | 2131 | } |
| 1674 | 2132 | |
| 1675 | 2133 | /** |
| 2134 | + * Properties schema.org defines as URLs. | |
| 2135 | + * | |
| 2136 | + * @since 2.0.2 | |
| 2137 | + * @var string[] | |
| 2138 | + */ | |
| 2139 | + private const URL_PROPERTIES = ['sameAs', 'url', 'logo', 'image']; | |
| 2140 | + | |
| 2141 | + /** | |
| 2142 | + * Whether a value is a URL safe to publish in structured data. | |
| 2143 | + * | |
| 2144 | + * @since 2.0.2 | |
| 2145 | + * | |
| 2146 | + * @param mixed $url Candidate value. | |
| 2147 | + * @return bool | |
| 2148 | + */ | |
| 2149 | + private function is_publishable_url($url): bool { | |
| 2150 | + if (!is_string($url) || '' === trim($url)) { | |
| 2151 | + return false; | |
| 2152 | + } | |
| 2153 | + | |
| 2154 | + if (!filter_var($url, FILTER_VALIDATE_URL)) { | |
| 2155 | + return false; | |
| 2156 | + } | |
| 2157 | + | |
| 2158 | + $scheme = wp_parse_url($url, PHP_URL_SCHEME); | |
| 2159 | + | |
| 2160 | + return in_array(strtolower((string) $scheme), ['http', 'https'], true); | |
| 2161 | + } | |
| 2162 | + | |
| 2163 | + /** | |
| 2164 | + * Drop values that are not URLs from URL-typed properties. | |
| 2165 | + * | |
| 2166 | + * An absent property is valid; one holding free text is not, and it can | |
| 2167 | + * invalidate the entity around it. Nested objects (`logo` and `image` are | |
| 2168 | + * frequently ImageObjects) are walked so a bad `url` inside one is caught | |
| 2169 | + * too. A property left with nothing is removed rather than emitted empty. | |
| 2170 | + * | |
| 2171 | + * @since 2.0.2 | |
| 2172 | + * | |
| 2173 | + * @param array $schema Decoded schema data. | |
| 2174 | + * @return array Schema carrying only publishable URLs. | |
| 2175 | + */ | |
| 2176 | + private function filter_entity_urls(array $schema): array { | |
| 2177 | + foreach ($schema as $key => $value) { | |
| 2178 | + if (is_array($value) && !in_array($key, self::URL_PROPERTIES, true)) { | |
| 2179 | + $schema[$key] = $this->filter_entity_urls($value); | |
| 2180 | + continue; | |
| 2181 | + } | |
| 2182 | + | |
| 2183 | + if (!in_array($key, self::URL_PROPERTIES, true)) { | |
| 2184 | + continue; | |
| 2185 | + } | |
| 2186 | + | |
| 2187 | + // A nested object (ImageObject and friends) carries its own url. | |
| 2188 | + if (is_array($value) && isset($value['@type'])) { | |
| 2189 | + $schema[$key] = $this->filter_entity_urls($value); | |
| 2190 | + continue; | |
| 2191 | + } | |
| 2192 | + | |
| 2193 | + if (is_array($value)) { | |
| 2194 | + $kept = []; | |
| 2195 | + | |
| 2196 | + foreach ($value as $item) { | |
| 2197 | + if (is_array($item)) { | |
| 2198 | + $kept[] = $this->filter_entity_urls($item); | |
| 2199 | + } elseif ($this->is_publishable_url($item)) { | |
| 2200 | + $kept[] = $item; | |
| 2201 | + } | |
| 2202 | + } | |
| 2203 | + | |
| 2204 | + if ([] === $kept) { | |
| 2205 | + unset($schema[$key]); | |
| 2206 | + } else { | |
| 2207 | + $schema[$key] = array_values($kept); | |
| 2208 | + } | |
| 2209 | + | |
| 2210 | + continue; | |
| 2211 | + } | |
| 2212 | + | |
| 2213 | + if (!$this->is_publishable_url($value)) { | |
| 2214 | + unset($schema[$key]); | |
| 2215 | + } | |
| 2216 | + } | |
| 2217 | + | |
| 2218 | + return $schema; | |
| 2219 | + } | |
| 2220 | + | |
| 2221 | + /** | |
| 2222 | + * Schema types whose `url` identifies the entity, not the page. | |
| 2223 | + * | |
| 2224 | + * On a Person or an Organization, `url` is that entity's own website, so | |
| 2225 | + * overwriting it with the permalink of whichever post the schema happens to | |
| 2226 | + * be deployed on is simply wrong. It also breaks graph assembly: the site | |
| 2227 | + * identity emits the same entity with its real `url`, and once the two | |
| 2228 | + * copies disagree they can no longer be recognised as one entity (#479). | |
| 2229 | + * | |
| 2230 | + * @since 2.0.2 | |
| 2231 | + * @var string[] | |
| 2232 | + */ | |
| 2233 | + private const ENTITY_URL_TYPES = ['Person', 'Organization', 'LocalBusiness']; | |
| 2234 | + | |
| 2235 | + /** | |
| 2236 | + * Replace a stored permalink snapshot with the post's live permalink. | |
| 2237 | + * | |
| 2238 | + * Only touches `url` and `mainEntityOfPage`, and only for post-like | |
| 2239 | + * contexts where a permalink actually exists. Identity entities are | |
| 2240 | + * exempt from the `url` rewrite — see self::ENTITY_URL_TYPES. | |
| 2241 | + * | |
| 2242 | + * @since 1.16.0 | |
| 2243 | + * | |
| 2244 | + * @param array $schema Decoded schema data. | |
| 2245 | + * @param string $context_type Context type. | |
| 2246 | + * @param int|null $context_id Context ID. | |
| 2247 | + * @return array Schema with a current permalink. | |
| 2248 | + */ | |
| 2249 | + private function refresh_schema_permalink(array $schema, string $context_type, ?int $context_id): array { | |
| 2250 | + if ('site' === $context_type || empty($context_id)) { | |
| 2251 | + return $schema; | |
| 2252 | + } | |
| 2253 | + | |
| 2254 | + $permalink = get_permalink($context_id); | |
| 2255 | + | |
| 2256 | + if (!$permalink) { | |
| 2257 | + return $schema; | |
| 2258 | + } | |
| 2259 | + | |
| 2260 | + $type = $schema['@type'] ?? ''; | |
| 2261 | + $type = is_array($type) ? reset($type) : $type; | |
| 2262 | + $is_entity = in_array((string) $type, self::ENTITY_URL_TYPES, true); | |
| 2263 | + | |
| 2264 | + if (isset($schema['url']) && !$is_entity) { | |
| 2265 | + $schema['url'] = $permalink; | |
| 2266 | + } | |
| 2267 | + | |
| 2268 | + if (isset($schema['mainEntityOfPage'])) { | |
| 2269 | + if (is_array($schema['mainEntityOfPage'])) { | |
| 2270 | + if (isset($schema['mainEntityOfPage']['@id'])) { | |
| 2271 | + $schema['mainEntityOfPage']['@id'] = $permalink; | |
| 2272 | + } | |
| 2273 | + } else { | |
| 2274 | + $schema['mainEntityOfPage'] = $permalink; | |
| 2275 | + } | |
| 2276 | + } | |
| 2277 | + | |
| 2278 | + return $schema; | |
| 2279 | + } | |
| 2280 | + | |
| 2281 | + /** | |
| 2282 | + * Normalise properties that stored snapshots may hold in a stale format. | |
| 2283 | + * | |
| 2284 | + * Deployed schema is written once and read forever, so a formatting fix in | |
| 2285 | + * the builder never reaches rows already on disk. Correcting on read means | |
| 2286 | + * existing sites benefit without a migration. | |
| 2287 | + * | |
| 2288 | + * Covers non-ISO-8601 dates (#465) and WP locales in inLanguage, which must | |
| 2289 | + * be a BCP-47 tag — en-US, not en_US (#473). Walks nested nodes so values | |
| 2290 | + * inside author/publisher/@graph entries are covered too. | |
| 2291 | + * | |
| 2292 | + * @since 1.16.0 | |
| 2293 | + * | |
| 2294 | + * @param array $schema Decoded schema data. | |
| 2295 | + * @return array Normalised schema. | |
| 2296 | + */ | |
| 2297 | + private function normalize_stored_schema(array $schema): array { | |
| 2298 | + static $date_keys = [ | |
| 2299 | + 'datePublished', 'dateModified', 'dateCreated', 'uploadDate', | |
| 2300 | + 'startDate', 'endDate', 'validFrom', 'validThrough', 'expires', | |
| 2301 | + ]; | |
| 2302 | + | |
| 2303 | + foreach ($schema as $key => $value) { | |
| 2304 | + if (is_array($value)) { | |
| 2305 | + $schema[$key] = $this->normalize_stored_schema($value); | |
| 2306 | + continue; | |
| 2307 | + } | |
| 2308 | + | |
| 2309 | + if ('inLanguage' === $key && is_string($value) && '' !== $value) { | |
| 2310 | + $schema[$key] = str_replace('_', '-', $value); | |
| 2311 | + continue; | |
| 2312 | + } | |
| 2313 | + | |
| 2314 | + if (!in_array($key, $date_keys, true) || !is_string($value) || '' === $value) { | |
| 2315 | + continue; | |
| 2316 | + } | |
| 2317 | + | |
| 2318 | + // Already ISO 8601 — leave it alone. | |
| 2319 | + if (preg_match('/^\d{4}-\d{2}-\d{2}T/', $value)) { | |
| 2320 | + continue; | |
| 2321 | + } | |
| 2322 | + | |
| 2323 | + $timestamp = strtotime($value); | |
| 2324 | + | |
| 2325 | + if (false !== $timestamp) { | |
| 2326 | + $schema[$key] = (string) wp_date('c', $timestamp); | |
| 2327 | + } | |
| 2328 | + } | |
| 2329 | + | |
| 2330 | + return $schema; | |
| 2331 | + } | |
| 2332 | + | |
| 2333 | + /** | |
| 1676 | 2334 | * Clean up duplicate schemas in database |
| 1677 | 2335 | * |
| 1678 | 2336 | * @since 1.0.0 |
| 1679 | 2337 | * |
| @@ -1687,18 +2345,18 @@ | ||
| 1687 | 2345 | $table_name = $wpdb->prefix . 'thinkrank_seo_schema'; |
| 1688 | 2346 | |
| 1689 | 2347 | if (null === $context_id) { |
| 1690 | 2348 | // Clean up duplicates for NULL context_id |
| 1691 | - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Schema cleanup requires direct database access | |
| 2349 | + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema cleanup requires direct database access | |
| 1692 | 2350 | $sql = sprintf( |
| 1693 | 2351 | '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', |
| 1694 | 2352 | $table_name, |
| 1695 | 2353 | $table_name |
| 1696 | 2354 | ); |
| 1697 | - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Schema cleanup requires direct database access | |
| 2355 | + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema cleanup requires direct database access | |
| 1698 | 2356 | $deleted = $wpdb->query( |
| 1699 | 2357 | $wpdb->prepare( |
| 1700 | - // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- SQL is properly prepared with placeholders | |
| 2358 | + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders | |
| 1701 | 2359 | $sql, |
| 1702 | 2360 | $context_type, |
| 1703 | 2361 | $context_type |
| 1704 | 2362 | ) |
| @@ -1704,18 +2362,18 @@ | ||
| 1704 | 2362 | ) |
| 1705 | 2363 | ); |
| 1706 | 2364 | } else { |
| 1707 | 2365 | // Clean up duplicates for specific context_id |
| 1708 | - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Schema cleanup requires direct database access | |
| 2366 | + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema cleanup requires direct database access | |
| 1709 | 2367 | $sql = sprintf( |
| 1710 | 2368 | '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', |
| 1711 | 2369 | $table_name, |
| 1712 | 2370 | $table_name |
| 1713 | 2371 | ); |
| 1714 | - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Schema cleanup requires direct database access | |
| 2372 | + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema cleanup requires direct database access | |
| 1715 | 2373 | $deleted = $wpdb->query( |
| 1716 | 2374 | $wpdb->prepare( |
| 1717 | - // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- SQL is properly prepared with placeholders | |
| 2375 | + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders | |
| 1718 | 2376 | $sql, |
| 1719 | 2377 | $context_type, |
| 1720 | 2378 | $context_id, |
| 1721 | 2379 | $context_type, |
| @@ -1725,51 +2383,154 @@ | ||
| 1725 | 2383 | } |
| 1726 | 2384 | |
| 1727 | 2385 | return $deleted ?: 0; |
| 1728 | 2386 | } |
| 2387 | + | |
| 1729 | 2388 | /** |
| 1730 | - * Delete all existing schemas for a context before storing new ones | |
| 2389 | + * Deactivate deployed schema rows for the given types. | |
| 1731 | 2390 | * |
| 2391 | + * Deployment was insert-only, so anything ever deployed to a context stayed | |
| 2392 | + * on the page forever — switching a post's schema type left the old one live | |
| 2393 | + * and deactivating a saved schema did nothing (#464). Rows are deactivated | |
| 2394 | + * rather than deleted so a later redeploy can revive them and so there is a | |
| 2395 | + * trail of what was published. | |
| 2396 | + * | |
| 2397 | + * @since 1.16.0 | |
| 2398 | + * | |
| 2399 | + * @param string $context_type Context type. | |
| 2400 | + * @param int|null $context_id Context ID. | |
| 2401 | + * @param string[] $schema_types Types to retire. | |
| 2402 | + * @return int Number of rows deactivated. | |
| 2403 | + */ | |
| 2404 | + private function retire_schema_types(string $context_type, ?int $context_id, array $schema_types): int { | |
| 2405 | + $schema_types = array_values(array_filter(array_map('strval', $schema_types), 'strlen')); | |
| 2406 | + | |
| 2407 | + if (empty($schema_types)) { | |
| 2408 | + return 0; | |
| 2409 | + } | |
| 2410 | + | |
| 2411 | + global $wpdb; | |
| 2412 | + | |
| 2413 | + $table_name = $wpdb->prefix . 'thinkrank_seo_schema'; | |
| 2414 | + $placeholders = implode(', ', array_fill(0, count($schema_types), '%s')); | |
| 2415 | + | |
| 2416 | + if (null === $context_id) { | |
| 2417 | + $sql = sprintf( | |
| 2418 | + 'UPDATE %s SET is_active = 0 WHERE context_type = %%s AND context_id IS NULL AND schema_type IN (%s)', | |
| 2419 | + $table_name, | |
| 2420 | + $placeholders | |
| 2421 | + ); | |
| 2422 | + $args = array_merge([$context_type], $schema_types); | |
| 2423 | + } else { | |
| 2424 | + $sql = sprintf( | |
| 2425 | + 'UPDATE %s SET is_active = 0 WHERE context_type = %%s AND context_id = %%d AND schema_type IN (%s)', | |
| 2426 | + $table_name, | |
| 2427 | + $placeholders | |
| 2428 | + ); | |
| 2429 | + $args = array_merge([$context_type, $context_id], $schema_types); | |
| 2430 | + } | |
| 2431 | + | |
| 2432 | + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Retiring deployed schema rows requires direct database access. | |
| 2433 | + $updated = $wpdb->query( | |
| 2434 | + $wpdb->prepare( | |
| 2435 | + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is built from an internal table name and generated placeholders. | |
| 2436 | + $sql, | |
| 2437 | + $args | |
| 2438 | + ) | |
| 2439 | + ); | |
| 2440 | + | |
| 2441 | + if ($updated && $this->cache_manager) { | |
| 2442 | + $this->cache_manager->invalidate_context_cache($context_type, $context_id); | |
| 2443 | + } | |
| 2444 | + | |
| 2445 | + return (int) ($updated ?: 0); | |
| 2446 | + } | |
| 2447 | + | |
| 2448 | + /** | |
| 2449 | + * Retire deployed types that are no longer in the user's Schema Types selection. | |
| 2450 | + * | |
| 2451 | + * An empty selection means "auto-detect", so nothing is retired in that case. | |
| 2452 | + * | |
| 2453 | + * @since 1.16.0 | |
| 2454 | + * | |
| 2455 | + * @param string $context_type Context type. | |
| 2456 | + * @param int|null $context_id Context ID. | |
| 2457 | + * @param array $enabled_types The user's selected types. | |
| 2458 | + * @return int Number of rows deactivated. | |
| 2459 | + */ | |
| 2460 | + private function retire_unselected_schema_types(string $context_type, ?int $context_id, array $enabled_types): int { | |
| 2461 | + if (empty($enabled_types)) { | |
| 2462 | + return 0; | |
| 2463 | + } | |
| 2464 | + | |
| 2465 | + $deployed = array_keys($this->get_deployed_schemas($context_type, $context_id)); | |
| 2466 | + $stale = array_diff($deployed, $enabled_types); | |
| 2467 | + | |
| 2468 | + return $this->retire_schema_types($context_type, $context_id, $stale); | |
| 2469 | + } | |
| 2470 | + | |
| 2471 | + /** | |
| 2472 | + * Delete stored schemas for a context before storing new ones. | |
| 2473 | + * | |
| 2474 | + * `$schema_types` scopes the delete to the types actually being rewritten. | |
| 2475 | + * Without it this wiped every type in the context, which silently destroyed | |
| 2476 | + * deployed schema whenever a caller regenerated a subset — and | |
| 2477 | + * auto_deploy_schema_on_settings_change() regenerates one type at a time | |
| 2478 | + * (#454). Passing an empty array keeps the original clear-the-context | |
| 2479 | + * behaviour for callers that genuinely rewrite everything. | |
| 2480 | + * | |
| 1732 | 2481 | * @since 1.0.0 |
| 1733 | 2482 | * |
| 1734 | 2483 | * @param string $context_type Context type |
| 1735 | 2484 | * @param int|null $context_id Context ID |
| 2485 | + * @param string[] $schema_types Optional. Limit the delete to these types. | |
| 1736 | 2486 | * @return int Number of schemas deleted |
| 1737 | 2487 | */ |
| 1738 | - private function delete_existing_schemas(string $context_type, ?int $context_id): int { | |
| 2488 | + private function delete_existing_schemas(string $context_type, ?int $context_id, array $schema_types = []): int { | |
| 1739 | 2489 | global $wpdb; |
| 1740 | 2490 | |
| 1741 | 2491 | $table_name = $wpdb->prefix . 'thinkrank_seo_schema'; |
| 1742 | 2492 | |
| 2493 | + // Build an optional `AND schema_type IN (…)` clause with one prepared | |
| 2494 | + // placeholder per type, so the scoping cannot be injected through. | |
| 2495 | + $type_clause = ''; | |
| 2496 | + $type_values = []; | |
| 2497 | + $schema_types = array_values(array_filter(array_map('strval', $schema_types), 'strlen')); | |
| 2498 | + if (!empty($schema_types)) { | |
| 2499 | + $type_clause = ' AND schema_type IN (' . implode(', ', array_fill(0, count($schema_types), '%s')) . ')'; | |
| 2500 | + $type_values = $schema_types; | |
| 2501 | + } | |
| 2502 | + | |
| 1743 | 2503 | if (null === $context_id) { |
| 1744 | 2504 | // Delete all schemas for NULL context_id |
| 1745 | - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Schema deletion requires direct database access | |
| 2505 | + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema deletion requires direct database access | |
| 1746 | 2506 | $sql = sprintf( |
| 1747 | - 'DELETE FROM %s WHERE context_type = %%s AND context_id IS NULL', | |
| 1748 | - $table_name | |
| 2507 | + 'DELETE FROM %s WHERE context_type = %%s AND context_id IS NULL%s', | |
| 2508 | + $table_name, | |
| 2509 | + $type_clause | |
| 1749 | 2510 | ); |
| 1750 | - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Schema deletion requires direct database access | |
| 2511 | + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema deletion requires direct database access | |
| 1751 | 2512 | $deleted = $wpdb->query( |
| 1752 | 2513 | $wpdb->prepare( |
| 1753 | - // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- SQL is properly prepared with placeholders | |
| 2514 | + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders | |
| 1754 | 2515 | $sql, |
| 1755 | - $context_type | |
| 2516 | + array_merge([$context_type], $type_values) | |
| 1756 | 2517 | ) |
| 1757 | 2518 | ); |
| 1758 | 2519 | } else { |
| 1759 | 2520 | // Delete all schemas for specific context_id |
| 1760 | - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Schema deletion requires direct database access | |
| 2521 | + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema deletion requires direct database access | |
| 1761 | 2522 | $sql = sprintf( |
| 1762 | - 'DELETE FROM %s WHERE context_type = %%s AND context_id = %%d', | |
| 1763 | - $table_name | |
| 2523 | + 'DELETE FROM %s WHERE context_type = %%s AND context_id = %%d%s', | |
| 2524 | + $table_name, | |
| 2525 | + $type_clause | |
| 1764 | 2526 | ); |
| 1765 | - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Schema deletion requires direct database access | |
| 2527 | + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema deletion requires direct database access | |
| 1766 | 2528 | $deleted = $wpdb->query( |
| 1767 | 2529 | $wpdb->prepare( |
| 1768 | - // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- SQL is properly prepared with placeholders | |
| 2530 | + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders | |
| 1769 | 2531 | $sql, |
| 1770 | - $context_type, | |
| 1771 | - $context_id | |
| 2532 | + array_merge([$context_type, $context_id], $type_values) | |
| 1772 | 2533 | ) |
| 1773 | 2534 | ); |
| 1774 | 2535 | } |
| 1775 | 2536 | |
| @@ -1775,14 +2536,8 @@ | ||
| 1775 | 2536 | |
| 1776 | 2537 | return $deleted ?: 0; |
| 1777 | 2538 | } |
| 1778 | 2539 | |
| 1779 | - | |
| 1780 | - | |
| 1781 | - | |
| 1782 | - | |
| 1783 | - | |
| 1784 | - | |
| 1785 | 2540 | /** |
| 1786 | 2541 | * Get business data from Site Identity Local settings |
| 1787 | 2542 | * |
| 1788 | 2543 | * @return array |
| @@ -1803,55 +2558,8 @@ | ||
| 1803 | 2558 | 'business_hours' => $site_identity_settings['business_hours'] ?? [], |
| 1804 | 2559 | 'business_type' => $site_identity_settings['business_type'] ?? 'LocalBusiness' |
| 1805 | 2560 | ]; |
| 1806 | 2561 | } |
| 1807 | - | |
| 1808 | - /** | |
| 1809 | - * Build structured business content for schema generation | |
| 1810 | - * | |
| 1811 | - * @param array $business_data Business data from Local SEO | |
| 1812 | - * @return string | |
| 1813 | - */ | |
| 1814 | - private function build_business_content(array $business_data): string { | |
| 1815 | - $content_parts = []; | |
| 1816 | - | |
| 1817 | - if (!empty($business_data['business_name'])) { | |
| 1818 | - $content_parts[] = 'Business: ' . $business_data['business_name']; | |
| 1819 | - } | |
| 1820 | - | |
| 1821 | - // Build full address | |
| 1822 | - $address_parts = array_filter([ | |
| 1823 | - $business_data['business_address'] ?? '', | |
| 1824 | - $business_data['business_city'] ?? '', | |
| 1825 | - $business_data['business_state'] ?? '', | |
| 1826 | - $business_data['business_postal_code'] ?? '', | |
| 1827 | - $business_data['business_country'] ?? '' | |
| 1828 | - ]); | |
| 1829 | - | |
| 1830 | - if (!empty($address_parts)) { | |
| 1831 | - $content_parts[] = 'Address: ' . implode(', ', $address_parts); | |
| 1832 | - } | |
| 1833 | - | |
| 1834 | - if (!empty($business_data['business_phone'])) { | |
| 1835 | - $content_parts[] = 'Phone: ' . $business_data['business_phone']; | |
| 1836 | - } | |
| 1837 | - | |
| 1838 | - if (!empty($business_data['business_email'])) { | |
| 1839 | - $content_parts[] = 'Email: ' . $business_data['business_email']; | |
| 1840 | - } | |
| 1841 | - | |
| 1842 | - return implode('. ', $content_parts) . '.'; | |
| 1843 | - } | |
| 1844 | - | |
| 1845 | - /** | |
| 1846 | - * Get SEO settings for a specific context (overrides parent to include Site Identity data) | |
| 1847 | - * | |
| 1848 | - * @since 1.0.0 | |
| 1849 | - * | |
| 1850 | - * @param string $context_type The context type | |
| 1851 | - * @param int|null $context_id Optional. Context ID | |
| 1852 | - * @return array SEO settings array with Site Identity data included | |
| 1853 | - */ | |
| 1854 | 2562 | public function get_settings(string $context_type, ?int $context_id = null): array { |
| 1855 | 2563 | // Get base settings from parent |
| 1856 | 2564 | $settings = parent::get_settings($context_type, $context_id); |
| 1857 | 2565 | |
| @@ -1906,8 +2614,11 @@ | ||
| 1906 | 2614 | 'site_url' => home_url(), |
| 1907 | 2615 | 'admin_email' => get_option('admin_email'), |
| 1908 | 2616 | 'language' => get_locale(), |
| 1909 | 2617 | 'timezone' => get_option('timezone_string'), |
| 2618 | + // Read by populate_website_schema(), so the deployed WebSite node | |
| 2619 | + // carries the same alternateName as the default one (#692). | |
| 2620 | + 'alternate_name' => $site_identity_settings['alternate_name'] ?? '', | |
| 1910 | 2621 | 'founded_date' => $site_identity_settings['founded_date'] ?? '', |
| 1911 | 2622 | 'founder_name' => $site_identity_settings['founder_name'] ?? '', |
| 1912 | 2623 | 'company_type' => $site_identity_settings['company_type'] ?? 'Organization', |
| 1913 | 2624 | // Site Identity assets |
| @@ -1932,9 +2643,9 @@ | ||
| 1932 | 2643 | 'person_address' => $schema_settings['person_address'] ?? '', |
| 1933 | 2644 | 'person_birth_date' => $schema_settings['person_birth_date'] ?? '', |
| 1934 | 2645 | 'person_nationality' => $schema_settings['person_nationality'] ?? '', |
| 1935 | 2646 | 'person_works_for' => $schema_settings['person_works_for'] ?? '', |
| 1936 | - 'person_same_as' => $schema_settings['person_same_as'] ?? array(), | |
| 2647 | + 'person_same_as' => $schema_settings['person_same_as'] ?? [], | |
| 1937 | 2648 | |
| 1938 | 2649 | // Website schema settings (site-wide) |
| 1939 | 2650 | 'website_name' => $schema_settings['website_name'] ?? '', |
| 1940 | 2651 | 'website_url' => $schema_settings['website_url'] ?? '', |
| @@ -1947,8 +2658,11 @@ | ||
| 1947 | 2658 | 'organization_social_twitter' => $schema_settings['organization_social_twitter'] ?? '', |
| 1948 | 2659 | 'organization_social_linkedin' => $schema_settings['organization_social_linkedin'] ?? '', |
| 1949 | 2660 | 'organization_social_instagram' => $schema_settings['organization_social_instagram'] ?? '', |
| 1950 | 2661 | 'organization_social_youtube' => $schema_settings['organization_social_youtube'] ?? '', |
| 2662 | + 'organization_social_pinterest' => $schema_settings['organization_social_pinterest'] ?? '', | |
| 2663 | + 'organization_social_whatsapp' => $schema_settings['organization_social_whatsapp'] ?? '', | |
| 2664 | + 'organization_social_telegram' => $schema_settings['organization_social_telegram'] ?? '', | |
| 1951 | 2665 | // Contact point information |
| 1952 | 2666 | 'organization_contact_type' => $schema_settings['organization_contact_type'] ?? 'customer service', |
| 1953 | 2667 | 'organization_contact_phone' => $schema_settings['organization_contact_phone'] ?? '', |
| 1954 | 2668 | 'organization_contact_email' => $schema_settings['organization_contact_email'] ?? '', |
| @@ -1986,19 +2700,5 @@ | ||
| 1986 | 2700 | 'social_profiles' => $social_profiles, |
| 1987 | 2701 | 'social_settings' => $social_settings |
| 1988 | 2702 | ]; |
| 1989 | 2703 | } |
| 1990 | - | |
| 1991 | - | |
| 1992 | - | |
| 1993 | - /** | |
| 1994 | - * Get business content from Local SEO settings if available | |
| 1995 | - * | |
| 1996 | - * @return string | |
| 1997 | - */ | |
| 1998 | - private function get_business_content_from_settings(): string { | |
| 1999 | - $business_data = $this->get_business_data_from_local_seo(); | |
| 2000 | - return $this->build_business_content($business_data); | |
| 2001 | - } | |
| 2002 | - | |
| 2003 | - | |
| 2004 | 2704 | } |