PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.9.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.9.0
2.9.0 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 All 50 releases
← All changes | includes/seo/class-schema-management-system.php +940 -240 1.0.0 → 2.9.0 View file →
@@ -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 }