PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.10.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.10.0
2.11.0 2.10.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 All 52 releases
← All changes | includes/seo/class-schema-management-system.php +996 -241 1.0.1 → 2.10.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'],
@@ -193,13 +201,43 @@
193 201 'WebPage' => [
194 202 'name' => 'WebPage',
195 203 'description' => 'Individual web pages',
196 204 'required_properties' => ['name', 'url'],
205 + // 'post' as well as 'page': the per-page selector reaches this for
206 + // any post type, and a registry limited to 'page' silently produced
207 + // nothing for the rest (#624).
197 208 'recommended_properties' => ['description', 'author', 'datePublished', 'breadcrumb'],
198 209 'rich_snippets' => ['webpage', 'breadcrumb'],
199 - 'context_types' => ['page'],
210 + 'context_types' => ['page', 'post'],
200 211 'priority' => 'medium'
201 212 ],
213 + 'AboutPage' => [
214 + 'name' => 'AboutPage',
215 + 'description' => 'A page describing the organisation or person behind the site',
216 + 'required_properties' => ['name', 'url'],
217 + 'recommended_properties' => ['description', 'author', 'datePublished', 'breadcrumb'],
218 + 'rich_snippets' => ['webpage', 'breadcrumb'],
219 + 'context_types' => ['page', 'post'],
220 + 'priority' => 'medium'
221 + ],
222 + 'ContactPage' => [
223 + 'name' => 'ContactPage',
224 + 'description' => 'A page giving contact details',
225 + 'required_properties' => ['name', 'url'],
226 + 'recommended_properties' => ['description', 'author', 'datePublished', 'breadcrumb'],
227 + 'rich_snippets' => ['webpage', 'breadcrumb'],
228 + 'context_types' => ['page', 'post'],
229 + 'priority' => 'medium'
230 + ],
231 + 'ProfilePage' => [
232 + 'name' => 'ProfilePage',
233 + 'description' => 'A page about a single person or organisation',
234 + 'required_properties' => ['name', 'url'],
235 + 'recommended_properties' => ['description', 'author', 'datePublished', 'breadcrumb'],
236 + 'rich_snippets' => ['webpage', 'breadcrumb'],
237 + 'context_types' => ['page', 'post'],
238 + 'priority' => 'medium'
239 + ],
202 240 'FAQPage' => [
203 241 'name' => 'FAQPage',
204 242 'description' => 'Frequently Asked Questions pages',
205 243 'required_properties' => ['mainEntity'],
@@ -313,8 +351,19 @@
313 351 */
314 352 private ?Schema_Cache_Manager $cache_manager = null;
315 353
316 354 /**
355 + * Whether the foreign-settings listener has been registered this request.
356 + *
357 + * Static because `thinkrank_seo_settings_saved` is a global hook — one
358 + * listener serves every instance. See the constructor for why (#463).
359 + *
360 + * @since 1.16.0
361 + * @var bool
362 + */
363 + private static bool $foreign_settings_listener_registered = false;
364 +
365 + /**
317 366 * Constructor
318 367 *
319 368 * @since 1.0.0
320 369 */
@@ -330,11 +379,117 @@
330 379 $this->initialize_schema_builder();
331 380
332 381 // Initialize Schema Cache Manager for performance optimization
333 382 $this->initialize_cache_manager();
383 +
384 + // LocalBusiness and Organization both read Business Info, which Site
385 + // Identity owns. Without this, editing an address or phone number never
386 + // refreshed the deployed schema (#455).
387 + //
388 + // Registered at most once per request. WordPress keys callbacks by
389 + // object hash, so binding $this here added a fresh listener for every
390 + // instance — and this class is constructed from inside the very callback
391 + // it registers, which doubled the listener count on every settings save
392 + // (#463). The guard is static because the hook itself is global.
393 + if (!self::$foreign_settings_listener_registered) {
394 + self::$foreign_settings_listener_registered = true;
395 + add_action('thinkrank_seo_settings_saved', [$this, 'refresh_schema_for_foreign_settings'], 10, 4);
396 + }
334 397 }
335 398
336 399 /**
400 + * Regenerate schema when another manager saves settings this schema reads.
401 + *
402 + * Site Identity owns the Business Info fields that feed LocalBusiness and
403 + * the Organization address/contactPoint, so a save there has to refresh the
404 + * deployed schema even though no schema setting changed.
405 + *
406 + * @since 2.0.2
407 + *
408 + * @param string $manager_type Settings category that was saved.
409 + * @param array $settings Settings that were written.
410 + * @param string $context_type Context type.
411 + * @param int|null $context_id Context ID.
412 + * @return void
413 + */
414 + public function refresh_schema_for_foreign_settings(
415 + string $manager_type,
416 + array $settings,
417 + string $context_type,
418 + ?int $context_id
419 + ): void {
420 + if ('site_identity' !== $manager_type) {
421 + return;
422 + }
423 +
424 + $business_keys = [
425 + 'business_name', 'business_type', 'business_address', 'business_city',
426 + 'business_state', 'business_postal_code', 'business_country',
427 + 'business_phone', 'business_email', 'business_hours',
428 + 'business_latitude', 'business_longitude', 'business_price_range',
429 + ];
430 +
431 + // Which deployed types a Site Identity key can invalidate. The business
432 + // block feeds LocalBusiness and Organization; alternate_name feeds the
433 + // WebSite node, which had no entry here at all — so editing it left the
434 + // deployed schema showing the previous value until something else
435 + // happened to redeploy (#692).
436 + $refresh_types = [];
437 +
438 + if (!empty(array_intersect_key($settings, array_flip($business_keys)))) {
439 + $refresh_types[] = 'LocalBusiness';
440 + $refresh_types[] = 'Organization';
441 + }
442 +
443 + if (array_key_exists('alternate_name', $settings)) {
444 + $refresh_types[] = 'WebSite';
445 + }
446 +
447 + if (empty($refresh_types)) {
448 + return;
449 + }
450 +
451 + $schema_settings = $this->get_settings($context_type, $context_id);
452 + if (empty($schema_settings['auto_deploy'])) {
453 + return;
454 + }
455 +
456 + // Only refresh types that are actually deployed, so this never adds a
457 + // type the admin did not enable.
458 + $deployed = array_keys((array) $this->get_deployed_schemas($context_type, $context_id));
459 + $affected = array_values(array_intersect($deployed, $refresh_types));
460 +
461 + if (empty($affected)) {
462 + return;
463 + }
464 +
465 + try {
466 + $generation = $this->generate_schema_markup($context_type, $context_id, $affected);
467 +
468 + // Deploy the types that validated, not all-or-nothing. Gating on
469 + // deployment_ready meant one invalid type blocked every valid one
470 + // in the same batch (#470).
471 + $deployable = [];
472 + foreach ($affected as $type) {
473 + if (!empty($generation['generated_schemas'][$type])
474 + && !empty($generation['validation_results'][$type]['is_valid'])
475 + ) {
476 + $deployable[$type] = $generation['generated_schemas'][$type];
477 + }
478 + }
479 +
480 + if (!empty($deployable)) {
481 + $this->deploy_schema_markup($context_type, $context_id, $deployable);
482 + }
483 + } catch (\Exception $e) {
484 + if (defined('WP_DEBUG') && WP_DEBUG) {
485 + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
486 + error_log('ThinkRank: Business Info schema refresh failed: ' . $e->getMessage());
487 + }
488 + }
489 + }
490 +
491 + /**
337 492 * Initialize Schema Builder
338 493 *
339 494 * @return void
340 495 */
@@ -360,10 +515,20 @@
360 515 require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-schema-cache-manager.php';
361 516 }
362 517
363 518 if (class_exists('ThinkRank\\SEO\\Schema_Cache_Manager')) {
364 - // Get cache duration from deployment config
519 + // Honour the stored cache_duration setting. It is exposed in
520 + // get_settings_schema() (min 300 / max 86400), validated, persisted
521 + // and surfaced through both abilities — but the cache manager was
522 + // always built from the hardcoded config value, so the setting had
523 + // no effect (#473). Falls back to the config default.
365 524 $cache_duration = $this->deployment_config['caching']['duration'] ?? 3600;
525 +
526 + $stored = $this->get_settings('site', null)['cache_duration'] ?? null;
527 + if (is_numeric($stored) && (int) $stored > 0) {
528 + $cache_duration = (int) $stored;
529 + }
530 +
366 531 $this->cache_manager = new Schema_Cache_Manager($cache_duration);
367 532 }
368 533 }
369 534
@@ -371,12 +536,19 @@
371 536 * Generate schema markup with comprehensive content analysis integration
372 537 *
373 538 * @since 1.0.0
374 539 *
540 + * Generation is read-only by default. Persisting the result is opt-in via
541 + * `$options['persist']`, because this method is also reached from the
542 + * front-end read path (get_output_data()) and from GET routes — where a
543 + * DELETE + INSERT would destroy the admin's deployed rows and publish
544 + * types nobody deployed (#460).
545 + *
375 546 * @param string $context_type Context type
376 547 * @param int|null $context_id Context ID
377 548 * @param array $schema_types Schema types to generate
378 - * @param array $options Generation options
549 + * @param array $options Generation options. Pass `persist => true`
550 + * from explicit write paths only.
379 551 * @return array Comprehensive schema generation results
380 552 */
381 553 public function generate_schema_markup(string $context_type, ?int $context_id, array $schema_types = [], array $options = []): array {
382 554 $generation = [
@@ -447,10 +619,16 @@
447 619
448 620 // Check deployment readiness
449 621 $generation['deployment_ready'] = $this->check_deployment_readiness($generation['validation_results']);
450 622
451 - // Store schema data
452 - $this->store_schema_data($context_type, $context_id, $generation);
623 + // Persistence belongs to deployment, not generation. Every write path
624 + // (refresh_schema_for_foreign_settings(), auto_deploy_schema_on_settings_change(),
625 + // the deploy route) calls deploy_schema_markup() straight after generating,
626 + // so nothing needs to opt in today — the flag exists to keep this an
627 + // explicit decision rather than an accident.
628 + if (!empty($options['persist'])) {
629 + $this->store_schema_data($context_type, $context_id, $generation);
630 + }
453 631
454 632 return $generation;
455 633 }
456 634
@@ -578,8 +756,24 @@
578 756
579 757 // Determine deployment method
580 758 $deployment['deployment_method'] = $this->determine_deployment_method($options);
581 759
760 + // When the caller owns the whole context — the user pressing Deploy, where
761 + // the payload is exactly what the preview showed — anything not in that
762 + // payload should come off the page (#464). Incremental callers such as
763 + // auto_deploy_schema_on_settings_change() pass only the types they
764 + // regenerated, so they must NOT retire the rest.
765 + if (!empty($options['authoritative'])) {
766 + $deployment['retired_schemas'] = $this->retire_schema_types(
767 + $context_type,
768 + $context_id,
769 + array_diff(
770 + array_keys($this->get_deployed_schemas($context_type, $context_id)),
771 + array_keys($schema_data)
772 + )
773 + );
774 + }
775 +
582 776 // Deploy each schema
583 777 foreach ($schema_data as $schema_type => $schema) {
584 778 $deploy_result = $this->deploy_single_schema($schema, $schema_type, $deployment['deployment_method'], $context_type, $context_id);
585 779 $deployment['deployed_schemas'][$schema_type] = $deploy_result;
@@ -588,21 +782,91 @@
588 782 // Clean up duplicate schemas
589 783 $this->cleanup_duplicate_schemas($context_type, $context_id);
590 784
591 785 // CACHE INVALIDATION: Clear cache after successful deployment
786 + $cache_invalidated = false;
592 787 if ($this->cache_manager && !empty($deployment['deployed_schemas'])) {
593 788 $this->cache_manager->invalidate_context_cache($context_type, $context_id);
789 + $cache_invalidated = true;
594 790 }
595 791
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';
792 + $deployment['cache_status'] = $cache_invalidated
793 + ? ['cache_updated' => true, 'message' => 'Schema cache invalidated']
794 + : ['cache_updated' => false, 'message' => 'No schema cache to invalidate'];
600 795
796 + // Post-deployment verification: read back through the same accessor the
797 + // front end uses, so a row that was written but is not retrievable (wrong
798 + // context, inactive, stale cache) is reported as a failure instead of
799 + // being assumed successful.
800 + $deployment['validation_post_deployment'] = $this->verify_deployment(
801 + $context_type,
802 + $context_id,
803 + array_keys($deployment['deployed_schemas'])
804 + );
805 +
806 + $writes_ok = !empty($deployment['deployed_schemas']);
807 + foreach ($deployment['deployed_schemas'] as $deploy_result) {
808 + if (empty($deploy_result['deployed'])) {
809 + $writes_ok = false;
810 + break;
811 + }
812 + }
813 +
814 + $deployment['deployment_status'] =
815 + ($writes_ok && !empty($deployment['validation_post_deployment']['validation_passed']))
816 + ? 'success'
817 + : 'failed';
818 +
601 819 return $deployment;
602 820 }
603 821
604 822 /**
823 + * Verify deployed schema is retrievable after a deploy.
824 + *
825 + * Reads back through get_deployed_schemas() — the same accessor
826 + * Frontend\SEO_Manager::output_site_schema_markup() uses to emit schema — so
827 + * the check reflects what will actually reach the page rather than only that
828 + * an INSERT returned without error.
829 + *
830 + * @since 1.32.0
831 + *
832 + * @param string $context_type Context type
833 + * @param int|null $context_id Context ID
834 + * @param array $expected_types Schema types that were just deployed
835 + * @return array Validation result
836 + */
837 + private function verify_deployment(string $context_type, ?int $context_id, array $expected_types): array {
838 + if (empty($expected_types)) {
839 + return [
840 + 'validation_passed' => false,
841 + 'message' => 'No schema was deployed',
842 + 'missing_types' => []
843 + ];
844 + }
845 +
846 + $retrieved = $this->get_deployed_schemas($context_type, $context_id);
847 + $missing = array_values(array_diff($expected_types, array_keys($retrieved)));
848 +
849 + if (!empty($missing)) {
850 + return [
851 + 'validation_passed' => false,
852 + 'message' => sprintf(
853 + /* translators: %s: comma-separated list of schema types */
854 + __('Deployed schema could not be read back: %s', 'thinkrank'),
855 + implode(', ', $missing)
856 + ),
857 + 'missing_types' => $missing
858 + ];
859 + }
860 +
861 + return [
862 + 'validation_passed' => true,
863 + 'message' => __('Schema deployed and read back from storage', 'thinkrank'),
864 + 'missing_types' => []
865 + ];
866 + }
867 +
868 + /**
605 869 * Track schema performance and rich snippet appearances
606 870 *
607 871 * @since 1.0.0
608 872 *
@@ -717,9 +981,11 @@
717 981 'validation_results' => [],
718 982 'rich_snippets_preview' => [],
719 983 'performance_data' => [],
720 984 'recommendations' => [],
721 - 'enabled' => true
985 + // Report the real setting. Hardcoding true here told every consumer
986 + // the feature was on even when the master switch was off (#461).
987 + 'enabled' => (bool) ($settings['enabled'] ?? true)
722 988 ];
723 989
724 990 // Get enabled schema types
725 991 $enabled_types = $settings['enabled_schema_types'] ?? [];
@@ -827,15 +1093,13 @@
827 1093 // For site context: only apply site-level schema settings
828 1094 if ($context_type === 'site') {
829 1095 // Add local business schema if enabled
830 1096 if ($options['enable_local_business'] ?? false) {
831 - if (!in_array('LocalBusiness', $enabled_types)) {
1097 + if (!in_array('LocalBusiness', $enabled_types, true)) {
832 1098 $enabled_types[] = 'LocalBusiness';
833 1099 }
834 1100 }
835 1101
836 -
837 -
838 1102 return $enabled_types;
839 1103 }
840 1104
841 1105 // For post/page context: apply all schema settings (metabox functionality)
@@ -841,9 +1105,9 @@
841 1105 // For post/page context: apply all schema settings (metabox functionality)
842 1106
843 1107 // Add article schema if enabled and context is appropriate
844 1108 if ($options['enable_article_schema'] ?? false) {
845 - if (in_array($context_type, ['post', 'page']) && !in_array('Article', $enabled_types)) {
1109 + if (in_array($context_type, ['post', 'page'], true) && !in_array('Article', $enabled_types, true)) {
846 1110 $enabled_types[] = 'Article';
847 1111 }
848 1112 }
849 1113
@@ -848,9 +1112,9 @@
848 1112 }
849 1113
850 1114 // Add FAQ schema if enabled
851 1115 if ($options['enable_faq_schema'] ?? false) {
852 - if (!in_array('FAQPage', $enabled_types)) {
1116 + if (!in_array('FAQPage', $enabled_types, true)) {
853 1117 $enabled_types[] = 'FAQPage';
854 1118 }
855 1119 }
856 1120
@@ -855,9 +1119,9 @@
855 1119 }
856 1120
857 1121 // Add How-To schema if enabled
858 1122 if ($options['enable_howto_schema'] ?? false) {
859 - if (!in_array('HowTo', $enabled_types)) {
1123 + if (!in_array('HowTo', $enabled_types, true)) {
860 1124 $enabled_types[] = 'HowTo';
861 1125 }
862 1126 }
863 1127
@@ -862,9 +1126,9 @@
862 1126 }
863 1127
864 1128 // Add product schema if enabled and context is appropriate
865 1129 if ($options['enable_product_schema'] ?? false) {
866 - if ($context_type === 'product' && !in_array('Product', $enabled_types)) {
1130 + if ($context_type === 'product' && !in_array('Product', $enabled_types, true)) {
867 1131 $enabled_types[] = 'Product';
868 1132 }
869 1133 }
870 1134
@@ -869,9 +1133,9 @@
869 1133 }
870 1134
871 1135 // Add local business schema if enabled
872 1136 if ($options['enable_local_business'] ?? false) {
873 - if (!in_array('LocalBusiness', $enabled_types)) {
1137 + if (!in_array('LocalBusiness', $enabled_types, true)) {
874 1138 $enabled_types[] = 'LocalBusiness';
875 1139 }
876 1140 }
877 1141
@@ -892,9 +1156,9 @@
892 1156 // For site context: only apply site-level schema settings
893 1157 if ($context_type === 'site') {
894 1158 // Add local business schema if enabled
895 1159 if ($settings['enable_local_business'] ?? false) {
896 - if (!in_array('LocalBusiness', $enabled_types)) {
1160 + if (!in_array('LocalBusiness', $enabled_types, true)) {
897 1161 $enabled_types[] = 'LocalBusiness';
898 1162 }
899 1163 }
900 1164
@@ -899,9 +1163,9 @@
899 1163 }
900 1164
901 1165 // Add breadcrumbs schema if enabled (site-wide feature)
902 1166 if ($settings['enable_breadcrumbs_schema'] ?? false) {
903 - if (!in_array('BreadcrumbList', $enabled_types)) {
1167 + if (!in_array('BreadcrumbList', $enabled_types, true)) {
904 1168 $enabled_types[] = 'BreadcrumbList';
905 1169 }
906 1170 }
907 1171
@@ -911,9 +1175,9 @@
911 1175 // For post/page context: apply all schema settings (metabox functionality)
912 1176
913 1177 // Add article schema if enabled and context is appropriate
914 1178 if ($settings['enable_article_schema'] ?? false) {
915 - if (in_array($context_type, ['post', 'page']) && !in_array('Article', $enabled_types)) {
1179 + if (in_array($context_type, ['post', 'page'], true) && !in_array('Article', $enabled_types, true)) {
916 1180 $enabled_types[] = 'Article';
917 1181 }
918 1182 }
919 1183
@@ -918,9 +1182,9 @@
918 1182 }
919 1183
920 1184 // Add FAQ schema if enabled
921 1185 if ($settings['enable_faq_schema'] ?? false) {
922 - if (!in_array('FAQPage', $enabled_types)) {
1186 + if (!in_array('FAQPage', $enabled_types, true)) {
923 1187 $enabled_types[] = 'FAQPage';
924 1188 }
925 1189 }
926 1190
@@ -925,9 +1189,9 @@
925 1189 }
926 1190
927 1191 // Add How-To schema if enabled
928 1192 if ($settings['enable_howto_schema'] ?? false) {
929 - if (!in_array('HowTo', $enabled_types)) {
1193 + if (!in_array('HowTo', $enabled_types, true)) {
930 1194 $enabled_types[] = 'HowTo';
931 1195 }
932 1196 }
933 1197
@@ -932,9 +1196,9 @@
932 1196 }
933 1197
934 1198 // Add product schema if enabled and context is appropriate
935 1199 if ($settings['enable_product_schema'] ?? false) {
936 - if ($context_type === 'product' && !in_array('Product', $enabled_types)) {
1200 + if ($context_type === 'product' && !in_array('Product', $enabled_types, true)) {
937 1201 $enabled_types[] = 'Product';
938 1202 }
939 1203 }
940 1204
@@ -939,9 +1203,9 @@
939 1203 }
940 1204
941 1205 // Add local business schema if enabled
942 1206 if ($settings['enable_local_business'] ?? false) {
943 - if (!in_array('LocalBusiness', $enabled_types)) {
1207 + if (!in_array('LocalBusiness', $enabled_types, true)) {
944 1208 $enabled_types[] = 'LocalBusiness';
945 1209 }
946 1210 }
947 1211
@@ -947,10 +1211,8 @@
947 1211
948 1212 return $enabled_types;
949 1213 }
950 1214
951 -
952 -
953 1215 /**
954 1216 * Get default organization logo for rich snippets
955 1217 *
956 1218 * @since 1.0.0
@@ -977,8 +1239,47 @@
977 1239 return home_url('/wp-content/plugins/thinkrank/assets/images/default-logo.jpg');
978 1240 }
979 1241
980 1242 /**
1243 + * Schema keys outside the shared config defaults.
1244 + *
1245 + * @since 2.0.1
1246 + *
1247 + * @return string[]
1248 + */
1249 + protected function additional_setting_keys(): array {
1250 + return [
1251 + 'enable_article_schema', 'enable_product_schema',
1252 + 'enable_faq_schema', 'enable_howto_schema',
1253 + ];
1254 + }
1255 +
1256 + /**
1257 + * Per-entity schema fields are an open set.
1258 + *
1259 + * Each schema type the UI can edit contributes its own field family —
1260 + * organization_*, person_*, website_*, business_*, software_*, howto_* —
1261 + * and a new type adds another. The families this manager owns are matched
1262 + * rather than enumerated, so adding a form does not silently start
1263 + * dropping its fields (#452).
1264 + *
1265 + * @since 2.0.1
1266 + *
1267 + * @return string[]
1268 + */
1269 + protected function dynamic_setting_key_patterns(): array {
1270 + return [
1271 + '/^organization_[a-z0-9_]+$/',
1272 + '/^person_[a-z0-9_]+$/',
1273 + '/^website_[a-z0-9_]+$/',
1274 + '/^business_[a-z0-9_]+$/',
1275 + '/^software_[a-z0-9_]+$/',
1276 + '/^howto_[a-z0-9_]+$/',
1277 + '/^product_[a-z0-9_]+$/',
1278 + ];
1279 + }
1280 +
1281 + /**
981 1282 * Get default settings for a context type (implements interface)
982 1283 *
983 1284 * @since 1.0.0
984 1285 *
@@ -1001,12 +1302,13 @@
1001 1302 return Schema_Settings_Config::get_settings_schema($context_type);
1002 1303 }
1003 1304
1004 1305 /**
1005 - * Save SEO settings with cache invalidation
1306 + * Save SEO settings with cache invalidation and auto-deployment
1006 1307 *
1007 1308 * Overrides parent method to add schema cache invalidation when settings change.
1008 1309 * This ensures cached schema data is refreshed when configuration changes.
1310 + * Also triggers auto-deployment of schema when enabled.
1009 1311 *
1010 1312 * @since 1.0.0
1011 1313 *
1012 1314 * @param string $context_type The context type
@@ -1022,12 +1324,215 @@
1022 1324 if ($success && $this->cache_manager) {
1023 1325 $this->cache_manager->invalidate_all_cache();
1024 1326 }
1025 1327
1328 + // AUTO-DEPLOY: Automatically regenerate and deploy schema when settings change
1329 + if ($success && !empty($settings['auto_deploy'])) {
1330 + $this->auto_deploy_schema_on_settings_change($context_type, $context_id, $settings);
1331 + }
1332 +
1026 1333 return $success;
1027 1334 }
1028 1335
1029 1336 /**
1337 + * Auto-deploy schema when settings change
1338 + *
1339 + * Automatically regenerates and deploys schema markup when organization or other
1340 + * schema settings are modified, ensuring the frontend output stays in sync.
1341 + *
1342 + * @since 1.0.0
1343 + *
1344 + * @param string $context_type Context type
1345 + * @param int|null $context_id Context ID
1346 + * @param array $settings Updated settings
1347 + * @return void
1348 + */
1349 + private function auto_deploy_schema_on_settings_change(string $context_type, ?int $context_id, array $settings): void {
1350 + // Determine which schema types need to be regenerated based on changed settings
1351 + $schema_types_to_regenerate = [];
1352 +
1353 + // Organization schema - regenerate if organization settings changed
1354 + if ($this->has_organization_settings_changed($settings)) {
1355 + $schema_types_to_regenerate[] = 'Organization';
1356 + }
1357 +
1358 + // Website schema - regenerate if website settings changed. The type is
1359 + // registered as 'WebSite' (capital S) in Schema_Factory / $schema_types;
1360 + // using 'Website' here made generate_schema_markup() silently skip it.
1361 + if ($this->has_website_settings_changed($settings)) {
1362 + $schema_types_to_regenerate[] = 'WebSite';
1363 + }
1364 +
1365 + // LocalBusiness schema - regenerate if business settings changed
1366 + if ($this->has_business_settings_changed($settings)) {
1367 + $schema_types_to_regenerate[] = 'LocalBusiness';
1368 + }
1369 +
1370 + // Person schema - regenerate if person settings changed
1371 + if ($this->has_person_settings_changed($settings)) {
1372 + $schema_types_to_regenerate[] = 'Person';
1373 + }
1374 +
1375 + // Honour the user's Schema Types selection. Without this the payload
1376 + // shape alone decided what shipped, so every save deployed all four
1377 + // types — including ones the user had explicitly deselected (#461).
1378 + // An empty selection means "auto", so only filter when one is set.
1379 + $enabled_types = $settings['enabled_schema_types'] ?? $this->get_settings($context_type, $context_id)['enabled_schema_types'] ?? [];
1380 +
1381 + if (!empty($enabled_types) && is_array($enabled_types)) {
1382 + $schema_types_to_regenerate = array_values(
1383 + array_intersect($schema_types_to_regenerate, $enabled_types)
1384 + );
1385 + }
1386 +
1387 + // Types that were deployed but are no longer wanted must come back off
1388 + // the page — deployment used to be additive-only (#464).
1389 + $this->retire_unselected_schema_types($context_type, $context_id, $enabled_types);
1390 +
1391 + // If no schema types need regeneration, return early
1392 + if (empty($schema_types_to_regenerate)) {
1393 + return;
1394 + }
1395 +
1396 + // Generate every affected type in ONE call. Generating them one at a
1397 + // time re-entered store_schema_data() per type, and each pass replaced
1398 + // the rows written by the previous one, so only the last type survived
1399 + // (#454). One batch also means one delete and one cache flush.
1400 + try {
1401 + $generation_result = $this->generate_schema_markup(
1402 + $context_type,
1403 + $context_id,
1404 + $schema_types_to_regenerate
1405 + );
1406 +
1407 + $deployable = [];
1408 + foreach ($schema_types_to_regenerate as $schema_type) {
1409 + // Only deploy what validated — see #470.
1410 + if (!empty($generation_result['generated_schemas'][$schema_type])
1411 + && !empty($generation_result['validation_results'][$schema_type]['is_valid'])
1412 + ) {
1413 + $deployable[$schema_type] = $generation_result['generated_schemas'][$schema_type];
1414 + }
1415 + }
1416 +
1417 + if (!empty($deployable)) {
1418 + $this->deploy_schema_markup($context_type, $context_id, $deployable);
1419 + }
1420 + } catch (\Exception $e) {
1421 + // Log error but don't fail the settings save
1422 + if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
1423 + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
1424 + error_log('ThinkRank: Auto-deploy failed for ' . implode(', ', $schema_types_to_regenerate) . ': ' . $e->getMessage());
1425 + }
1426 + }
1427 + }
1428 +
1429 + /**
1430 + * Check if organization settings have changed
1431 + *
1432 + * @since 1.0.0
1433 + *
1434 + * @param array $settings Updated settings
1435 + * @return bool True if organization settings changed
1436 + */
1437 + private function has_organization_settings_changed(array $settings): bool {
1438 + $org_keys = [
1439 + 'organization_name', 'organization_type', 'organization_logo', 'organization_url',
1440 + 'organization_description', 'organization_social_facebook', 'organization_social_twitter',
1441 + 'organization_social_linkedin', 'organization_social_instagram', 'organization_social_youtube',
1442 + 'organization_social_pinterest', 'organization_social_whatsapp', 'organization_social_telegram',
1443 + 'organization_contact_type', 'organization_contact_phone', 'organization_contact_email',
1444 + 'organization_contact_hours'
1445 + ];
1446 +
1447 + foreach ($org_keys as $key) {
1448 + if (isset($settings[$key])) {
1449 + return true;
1450 + }
1451 + }
1452 +
1453 + return false;
1454 + }
1455 +
1456 + /**
1457 + * Check if website settings have changed
1458 + *
1459 + * @since 1.0.0
1460 + *
1461 + * @param array $settings Updated settings
1462 + * @return bool True if website settings changed
1463 + */
1464 + private function has_website_settings_changed(array $settings): bool {
1465 + // These are the keys the Website tab actually stores. It previously
1466 + // looked for site_name/site_description/site_url, which belong to Site
1467 + // Identity and never appear in a schema settings payload — so WebSite
1468 + // schema never auto-deployed no matter what was edited (#455).
1469 + $website_keys = [
1470 + 'website_name', 'website_url', 'website_description', 'website_author',
1471 + ];
1472 +
1473 + foreach ($website_keys as $key) {
1474 + if (isset($settings[$key])) {
1475 + return true;
1476 + }
1477 + }
1478 +
1479 + return false;
1480 + }
1481 +
1482 + /**
1483 + * Check if business settings have changed
1484 + *
1485 + * @since 1.0.0
1486 + *
1487 + * @param array $settings Updated settings
1488 + * @return bool True if business settings changed
1489 + */
1490 + private function has_business_settings_changed(array $settings): bool {
1491 + // Only the keys this manager actually stores. business_name/address/
1492 + // phone/hours live in the site_identity category and never reach a
1493 + // schema settings save, so keying off them meant LocalBusiness never
1494 + // auto-deployed (#455). Edits to those fields refresh LocalBusiness
1495 + // through the Site Identity save path instead — see
1496 + // refresh_schema_for_foreign_settings().
1497 + $business_keys = [
1498 + 'enable_local_business',
1499 + 'business_price_range',
1500 + 'business_geo_latitude',
1501 + 'business_geo_longitude',
1502 + 'business_opening_hours',
1503 + ];
1504 +
1505 + foreach ($business_keys as $key) {
1506 + if (isset($settings[$key])) {
1507 + return true;
1508 + }
1509 + }
1510 +
1511 + return false;
1512 + }
1513 +
1514 + /**
1515 + * Check if person settings have changed
1516 + *
1517 + * @since 1.0.0
1518 + *
1519 + * @param array $settings Updated settings
1520 + * @return bool True if person settings changed
1521 + */
1522 + private function has_person_settings_changed(array $settings): bool {
1523 + $person_keys = ['person_name', 'person_image', 'person_job_title', 'person_description'];
1524 +
1525 + foreach ($person_keys as $key) {
1526 + if (isset($settings[$key])) {
1527 + return true;
1528 + }
1529 + }
1530 +
1531 + return false;
1532 + }
1533 +
1534 + /**
1030 1535 * Auto-detect appropriate schema types for context
1031 1536 *
1032 1537 * @since 1.0.0
1033 1538 *
@@ -1039,8 +1544,29 @@
1039 1544 $detected_types = [];
1040 1545
1041 1546 switch ($context_type) {
1042 1547 case 'site':
1548 + // The admin's Schema Types selection is the answer to "what
1549 + // does this site need"; detection is only the fallback for an
1550 + // install that has not chosen yet (#456).
1551 + $settings = $this->get_settings($context_type, $context_id);
1552 + $enabled = array_values(array_filter(
1553 + array_map('strval', (array) ($settings['enabled_schema_types'] ?? [])),
1554 + 'strlen'
1555 + ));
1556 +
1557 + // Drop stale names the factory no longer registers rather than
1558 + // handing them to the builder to silently skip.
1559 + $enabled = array_values(array_filter(
1560 + $enabled,
1561 + fn($type) => isset($this->schema_types[$type])
1562 + ));
1563 +
1564 + if (!empty($enabled)) {
1565 + $detected_types = $enabled;
1566 + break;
1567 + }
1568 +
1043 1569 $detected_types = ['Organization'];
1044 1570 // Check if it's a local business
1045 1571 if ($this->is_local_business()) {
1046 1572 $detected_types[] = 'LocalBusiness';
@@ -1050,11 +1576,8 @@
1050 1576 $detected_types = ['Article'];
1051 1577 // Check content type for specific article types
1052 1578 if ($context_id) {
1053 1579 $post = get_post($context_id);
1054 - if ($post && $this->is_recipe_content($post->post_content)) {
1055 - $detected_types[] = 'Recipe';
1056 - }
1057 1580 if ($post && $this->is_how_to_content($post->post_content)) {
1058 1581 $detected_types[] = 'HowTo';
1059 1582 }
1060 1583 }
@@ -1063,9 +1586,9 @@
1063 1586 $detected_types = ['Article'];
1064 1587 if ($context_id) {
1065 1588 $page = get_post($context_id);
1066 1589 if ($page && $this->is_faq_content($page->post_content)) {
1067 - $detected_types[] = 'FAQ';
1590 + $detected_types[] = 'FAQPage';
1068 1591 }
1069 1592 }
1070 1593 break;
1071 1594 case 'product':
@@ -1102,9 +1625,9 @@
1102 1625 'business_data' => $this->get_business_data_from_local_seo(),
1103 1626 'site_data' => $this->get_site_data_for_schema(),
1104 1627 'social_data' => $this->get_social_data_for_schema()
1105 1628 ];
1106 - } elseif ($context_id && in_array($context_type, ['post', 'page', 'product'])) {
1629 + } elseif ($context_id && in_array($context_type, ['post', 'page', 'product'], true)) {
1107 1630 // Post/page/product data
1108 1631 $post = get_post($context_id);
1109 1632 if ($post) {
1110 1633 $content_data = [
@@ -1109,17 +1632,22 @@
1109 1632 if ($post) {
1110 1633 $content_data = [
1111 1634 'title' => $post->post_title,
1112 1635 'url' => get_permalink($post->ID),
1113 - 'excerpt' => $post->post_excerpt ?: wp_trim_words($post->post_content, 30),
1636 + 'excerpt' => $post->post_excerpt ?: \ThinkRank\Core\Seo_Text::trim_words($post->post_content, 30),
1114 1637 'content' => $post->post_content,
1115 1638 'author' => [
1116 1639 'name' => get_the_author_meta('display_name', $post->post_author),
1117 1640 'url' => get_author_posts_url($post->post_author)
1118 1641 ],
1119 - 'date' => $post->post_date,
1120 - 'modified' => $post->post_modified,
1642 + // ISO 8601 with offset. post_date/post_modified are raw
1643 + // MySQL columns in site-local time with no timezone, which
1644 + // Google rejects as "Invalid value in field datePublished"
1645 + // and drops the Article rich result (#465).
1646 + 'date' => get_the_date('c', $post),
1647 + 'modified' => get_the_modified_date('c', $post),
1121 1648 'image' => get_the_post_thumbnail_url($post->ID, 'full'),
1649 + 'focus_keywords' => Focus_Keywords::get($post->ID),
1122 1650 'business_data' => $this->get_business_data_from_local_seo(),
1123 1651 'site_data' => $this->get_site_data_for_schema(),
1124 1652 'social_data' => $this->get_social_data_for_schema()
1125 1653 ];
@@ -1152,9 +1680,12 @@
1152 1680 $content_data['url'] = $custom_data['post_url'];
1153 1681 }
1154 1682 }
1155 1683
1156 - // Add focus keyword if provided
1684 + // Add focus keyword(s) if provided
1685 + if (!empty($custom_data['focus_keywords']) && is_array($custom_data['focus_keywords'])) {
1686 + $content_data['focus_keywords'] = $custom_data['focus_keywords'];
1687 + }
1157 1688 if (!empty($custom_data['focus_keyword'])) {
1158 1689 $content_data['focus_keyword'] = $custom_data['focus_keyword'];
1159 1690 }
1160 1691
@@ -1194,12 +1725,8 @@
1194 1725
1195 1726 return $content_data;
1196 1727 }
1197 1728
1198 -
1199 -
1200 -
1201 -
1202 1729 /**
1203 1730 * Store schema data in database
1204 1731 *
1205 1732 * @since 1.0.0
@@ -1213,10 +1740,16 @@
1213 1740 global $wpdb;
1214 1741
1215 1742 $table_name = $wpdb->prefix . 'thinkrank_seo_schema';
1216 1743
1217 - // First, delete all existing schemas for this context to ensure clean storage
1218 - $this->delete_existing_schemas($context_type, $context_id);
1744 + // Replace only the types in this batch. Clearing the whole context
1745 + // destroyed types the caller never asked about — and callers do
1746 + // regenerate a subset, one type at a time (#454).
1747 + $generated_types = array_keys($generation['generated_schemas'] ?? []);
1748 + if (empty($generated_types)) {
1749 + return false;
1750 + }
1751 + $this->delete_existing_schemas($context_type, $context_id, $generated_types);
1219 1752
1220 1753 foreach ($generation['generated_schemas'] as $schema_type => $schema_data) {
1221 1754 // Prepare schema data with validation status embedded
1222 1755 $schema_data_with_validation = $schema_data;
@@ -1232,13 +1765,17 @@
1232 1765 'context_id' => $context_id,
1233 1766 'schema_type' => $schema_type,
1234 1767 'schema_data' => wp_json_encode($schema_data_with_validation),
1235 1768 'validation_status' => $generation['validation_results'][$schema_type]['is_valid'] ? 'valid' : 'invalid',
1236 - 'is_active' => $generation['deployment_ready'] ? 1 : 0
1769 + // Per-type, not batch-wide. deployment_ready is only true when
1770 + // EVERY type in the batch validated, so one invalid type (a site
1771 + // with no Business Info makes LocalBusiness invalid) deactivated
1772 + // all the valid ones alongside it (#470).
1773 + 'is_active' => !empty($generation['validation_results'][$schema_type]['is_valid']) ? 1 : 0
1237 1774 ];
1238 1775
1239 1776 // Insert new schema (existing ones were already deleted)
1240 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Schema insertion requires direct database access
1777 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema insertion requires direct database access
1241 1778 $wpdb->insert($table_name, $data);
1242 1779 }
1243 1780
1244 1781 // CACHE INVALIDATION: Clear cache after storing new schema data
@@ -1272,60 +1809,8 @@
1272 1809 */
1273 1810
1274 1811 // Removed complex AI integration methods - moved to separate services
1275 1812 // 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 1813 private function generate_rich_snippets_preview(array $schema_data, string $schema_type): array {
1329 1814 return [
1330 1815 'preview_type' => $schema_type,
1331 1816 'title' => $schema_data['headline'] ?? $schema_data['name'] ?? 'Title',
@@ -1434,22 +1919,8 @@
1434 1919
1435 1920 return false;
1436 1921 }
1437 1922
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 1923 private function is_how_to_content(string $content): bool {
1453 1924 $how_to_keywords = ['step', 'how to', 'tutorial', 'guide', 'instructions'];
1454 1925 $content_lower = strtolower($content);
1455 1926
@@ -1474,10 +1945,8 @@
1474 1945
1475 1946 return false;
1476 1947 }
1477 1948
1478 -
1479 -
1480 1949 private function determine_deployment_method(array $options): string {
1481 1950 // Always use JSON-LD as it's the only supported method
1482 1951 return 'json_ld';
1483 1952 }
@@ -1499,17 +1968,17 @@
1499 1968
1500 1969 // Check if schema already exists for this context and type
1501 1970 if (null === $context_id) {
1502 1971 // 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
1972 + // 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 1973 $sql = sprintf(
1505 1974 'SELECT schema_id FROM %s WHERE context_type = %%s AND context_id IS NULL AND schema_type = %%s',
1506 1975 $table_name
1507 1976 );
1508 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Schema deployment requires direct database access
1977 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema deployment requires direct database access
1509 1978 $existing = $wpdb->get_var(
1510 1979 $wpdb->prepare(
1511 - // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- SQL is properly prepared with placeholders
1980 + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
1512 1981 $sql,
1513 1982 $context_type,
1514 1983 $schema_type
1515 1984 )
@@ -1515,17 +1984,17 @@
1515 1984 )
1516 1985 );
1517 1986 } else {
1518 1987 // 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
1988 + // 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 1989 $sql = sprintf(
1521 1990 'SELECT schema_id FROM %s WHERE context_type = %%s AND context_id = %%d AND schema_type = %%s',
1522 1991 $table_name
1523 1992 );
1524 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Schema deployment requires direct database access
1993 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema deployment requires direct database access
1525 1994 $existing = $wpdb->get_var(
1526 1995 $wpdb->prepare(
1527 - // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- SQL is properly prepared with placeholders
1996 + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
1528 1997 $sql,
1529 1998 $context_type,
1530 1999 $context_id,
1531 2000 $schema_type
@@ -1534,9 +2003,9 @@
1534 2003 }
1535 2004
1536 2005 if ($existing) {
1537 2006 // Update existing deployment
1538 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Schema update requires direct database access
2007 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema update requires direct database access
1539 2008 $result = $wpdb->update(
1540 2009 $table_name,
1541 2010 [
1542 2011 'schema_data' => wp_json_encode($schema),
@@ -1550,9 +2019,9 @@
1550 2019 );
1551 2020
1552 2021 } else {
1553 2022 // Insert new deployment
1554 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Schema insertion requires direct database access
2023 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema insertion requires direct database access
1555 2024 $result = $wpdb->insert(
1556 2025 $table_name,
1557 2026 $deployment_data,
1558 2027 ['%s', '%d', '%s', '%s', '%s', '%d']
@@ -1567,10 +2036,8 @@
1567 2036 'schema_id' => $existing ?: $wpdb->insert_id
1568 2037 ];
1569 2038 }
1570 2039
1571 -
1572 -
1573 2040 /**
1574 2041 * Get deployed schemas for frontend integration
1575 2042 *
1576 2043 * PERFORMANCE OPTIMIZED: This method now uses:
@@ -1597,10 +2064,8 @@
1597 2064 }
1598 2065
1599 2066 global $wpdb;
1600 2067
1601 -
1602 -
1603 2068 // Use existing seo_schema table
1604 2069 $table_name = $wpdb->prefix . 'thinkrank_seo_schema';
1605 2070
1606 2071 // OPTIMIZED QUERY: Use window function approach to eliminate correlated subquery
@@ -1605,17 +2070,17 @@
1605 2070
1606 2071 // OPTIMIZED QUERY: Use window function approach to eliminate correlated subquery
1607 2072 // This leverages the new composite index: idx_context_schema_active (context_type, schema_type, is_active, created_at DESC)
1608 2073 if (null === $context_id) {
1609 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Schema retrieval requires direct database access
2074 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema retrieval requires direct database access
1610 2075 $sql = sprintf(
1611 2076 '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 2077 $table_name
1613 2078 );
1614 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Schema retrieval requires direct database access
2079 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema retrieval requires direct database access
1615 2080 $deployed_schemas = $wpdb->get_results(
1616 2081 $wpdb->prepare(
1617 - // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- SQL is properly prepared with placeholders
2082 + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
1618 2083 $sql,
1619 2084 $context_type
1620 2085 ),
1621 2086 ARRAY_A
@@ -1620,17 +2085,17 @@
1620 2085 ),
1621 2086 ARRAY_A
1622 2087 );
1623 2088 } else {
1624 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Schema retrieval requires direct database access
2089 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema retrieval requires direct database access
1625 2090 $sql = sprintf(
1626 2091 '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 2092 $table_name
1628 2093 );
1629 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Schema retrieval requires direct database access
2094 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema retrieval requires direct database access
1630 2095 $deployed_schemas = $wpdb->get_results(
1631 2096 $wpdb->prepare(
1632 - // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- SQL is properly prepared with placeholders
2097 + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
1633 2098 $sql,
1634 2099 $context_type,
1635 2100 $context_id
1636 2101 ),
@@ -1637,13 +2102,15 @@
1637 2102 ARRAY_A
1638 2103 );
1639 2104 }
1640 2105
2106 + // Deliberately no early return on an empty result: it has to reach the
2107 + // cache write below. Most URLs have no deployed schema, so gating the
2108 + // write on a non-empty result made the majority of front-end requests
2109 + // permanent cache misses, re-running a ROW_NUMBER() OVER (PARTITION BY
2110 + // ...) query with two filesorts on every pageview (#392).
2111 + $deployed_schemas = $deployed_schemas ?: [];
1641 2112
1642 - if (empty($deployed_schemas)) {
1643 - return [];
1644 - }
1645 -
1646 2113 // Process schemas for return
1647 2114 $processed_schemas = [];
1648 2115 foreach ($deployed_schemas as $deployed_schema) {
1649 2116 $schema_data = json_decode($deployed_schema['schema_data'], true);
@@ -1654,8 +2121,26 @@
1654 2121 if (isset($schema_data['_validation'])) {
1655 2122 unset($schema_data['_validation']);
1656 2123 }
1657 2124
2125 + // Deployed schema is a snapshot, so rows written before #465
2126 + // still carry raw MySQL datetimes. Normalise on read so the
2127 + // fix reaches existing sites without a migration.
2128 + $schema_data = $this->normalize_stored_schema($schema_data);
2129 +
2130 + // The permalink was frozen at deploy time, so schema deployed
2131 + // while a post was a draft advertised "?p=123" as both url and
2132 + // mainEntityOfPage forever — contradicting the node's own @id
2133 + // and the canonical (#470). Resolve it live instead.
2134 + $schema_data = $this->refresh_schema_permalink($schema_data, $context_type, $context_id);
2135 +
2136 + // schema.org types `sameAs`, `url`, `logo` and `image` as URLs,
2137 + // but the form stored whatever was typed, so free text entered
2138 + // in a social-profile field shipped as a sameAs member and made
2139 + // the whole entity invalid (#480). Drop bad values on read, so
2140 + // existing sites stop emitting them without a migration.
2141 + $schema_data = $this->filter_entity_urls($schema_data);
2142 +
1658 2143 $processed_schemas[$schema_type] = [
1659 2144 'data' => $schema_data,
1660 2145 'method' => 'json_ld', // Default method
1661 2146 'type' => $schema_type
@@ -1662,10 +2147,13 @@
1662 2147 ];
1663 2148 }
1664 2149 }
1665 2150
1666 - // CACHE LAYER: Store result in cache for future requests
1667 - if ($this->cache_manager && !empty($processed_schemas)) {
2151 + // CACHE LAYER: Store result in cache for future requests — including
2152 + // an empty one. Cache_Manager::set() wraps the payload in a metadata
2153 + // envelope, so an empty result is still stored as a truthy value and
2154 + // reads back as a hit rather than a miss (#392).
2155 + if ($this->cache_manager) {
1668 2156 $cache_key = $this->cache_manager->generate_deployed_schemas_key($context_type, $context_id);
1669 2157 $this->cache_manager->set($cache_key, $processed_schemas);
1670 2158 }
1671 2159
@@ -1672,8 +2160,233 @@
1672 2160 return $processed_schemas;
1673 2161 }
1674 2162
1675 2163 /**
2164 + * Properties schema.org defines as URLs.
2165 + *
2166 + * @since 2.0.2
2167 + * @var string[]
2168 + */
2169 + private const URL_PROPERTIES = ['sameAs', 'url', 'logo', 'image'];
2170 +
2171 + /**
2172 + * Whether a value is a URL safe to publish in structured data.
2173 + *
2174 + * @since 2.0.2
2175 + *
2176 + * @param mixed $url Candidate value.
2177 + * @return bool
2178 + */
2179 + private function is_publishable_url($url): bool {
2180 + if (!is_string($url) || '' === trim($url)) {
2181 + return false;
2182 + }
2183 +
2184 + if (!filter_var($url, FILTER_VALIDATE_URL)) {
2185 + return false;
2186 + }
2187 +
2188 + $scheme = wp_parse_url($url, PHP_URL_SCHEME);
2189 +
2190 + return in_array(strtolower((string) $scheme), ['http', 'https'], true);
2191 + }
2192 +
2193 + /**
2194 + * Drop values that are not URLs from URL-typed properties.
2195 + *
2196 + * An absent property is valid; one holding free text is not, and it can
2197 + * invalidate the entity around it. Nested objects (`logo` and `image` are
2198 + * frequently ImageObjects) are walked so a bad `url` inside one is caught
2199 + * too. A property left with nothing is removed rather than emitted empty.
2200 + *
2201 + * @since 2.0.2
2202 + *
2203 + * @param array $schema Decoded schema data.
2204 + * @return array Schema carrying only publishable URLs.
2205 + */
2206 + private function filter_entity_urls(array $schema): array {
2207 + foreach ($schema as $key => $value) {
2208 + if (is_array($value) && !in_array($key, self::URL_PROPERTIES, true)) {
2209 + $schema[$key] = $this->filter_entity_urls($value);
2210 + continue;
2211 + }
2212 +
2213 + if (!in_array($key, self::URL_PROPERTIES, true)) {
2214 + continue;
2215 + }
2216 +
2217 + // A nested object (ImageObject and friends) carries its own url.
2218 + if (is_array($value) && isset($value['@type'])) {
2219 + $schema[$key] = $this->filter_entity_urls($value);
2220 + continue;
2221 + }
2222 +
2223 + if (is_array($value)) {
2224 + $kept = [];
2225 +
2226 + foreach ($value as $item) {
2227 + if (is_array($item)) {
2228 + $kept[] = $this->filter_entity_urls($item);
2229 + } elseif ($this->is_publishable_url($item)) {
2230 + $kept[] = $item;
2231 + }
2232 + }
2233 +
2234 + if ([] === $kept) {
2235 + unset($schema[$key]);
2236 + } else {
2237 + $schema[$key] = array_values($kept);
2238 + }
2239 +
2240 + continue;
2241 + }
2242 +
2243 + if (!$this->is_publishable_url($value)) {
2244 + unset($schema[$key]);
2245 + }
2246 + }
2247 +
2248 + return $schema;
2249 + }
2250 +
2251 + /**
2252 + * Schema types whose `url` identifies the entity, not the page.
2253 + *
2254 + * On a Person or an Organization, `url` is that entity's own website, so
2255 + * overwriting it with the permalink of whichever post the schema happens to
2256 + * be deployed on is simply wrong. It also breaks graph assembly: the site
2257 + * identity emits the same entity with its real `url`, and once the two
2258 + * copies disagree they can no longer be recognised as one entity (#479).
2259 + *
2260 + * @since 2.0.2
2261 + * @var string[]
2262 + */
2263 + private const ENTITY_URL_TYPES = ['Person', 'Organization', 'LocalBusiness'];
2264 +
2265 + /**
2266 + * Replace a stored permalink snapshot with the post's live permalink.
2267 + *
2268 + * Only touches `url` and `mainEntityOfPage`, and only for post-like
2269 + * contexts where a permalink actually exists. Identity entities are
2270 + * exempt from the `url` rewrite — see self::ENTITY_URL_TYPES.
2271 + *
2272 + * @since 1.16.0
2273 + *
2274 + * @param array $schema Decoded schema data.
2275 + * @param string $context_type Context type.
2276 + * @param int|null $context_id Context ID.
2277 + * @return array Schema with a current permalink.
2278 + */
2279 + private function refresh_schema_permalink(array $schema, string $context_type, ?int $context_id): array {
2280 + if ('site' === $context_type || empty($context_id)) {
2281 + return $schema;
2282 + }
2283 +
2284 + $permalink = get_permalink($context_id);
2285 +
2286 + if (!$permalink) {
2287 + return $schema;
2288 + }
2289 +
2290 + $type = $schema['@type'] ?? '';
2291 + $type = is_array($type) ? reset($type) : $type;
2292 + // A LocalBusiness is deployed under the subtype the site chose, so the
2293 + // exemption has to cover every subtype, not only the literal root.
2294 + $is_entity = in_array((string) $type, self::ENTITY_URL_TYPES, true)
2295 + || \ThinkRank\Config\Local_Business_Types_Config::is_local_business($type);
2296 +
2297 + if (isset($schema['url']) && !$is_entity) {
2298 + $schema['url'] = $permalink;
2299 + }
2300 +
2301 + if (isset($schema['mainEntityOfPage'])) {
2302 + if (is_array($schema['mainEntityOfPage'])) {
2303 + if (isset($schema['mainEntityOfPage']['@id'])) {
2304 + $schema['mainEntityOfPage']['@id'] = $permalink;
2305 + }
2306 + } else {
2307 + $schema['mainEntityOfPage'] = $permalink;
2308 + }
2309 + }
2310 +
2311 + return $schema;
2312 + }
2313 +
2314 + /**
2315 + * Normalise properties that stored snapshots may hold in a stale format.
2316 + *
2317 + * Deployed schema is written once and read forever, so a formatting fix in
2318 + * the builder never reaches rows already on disk. Correcting on read means
2319 + * existing sites benefit without a migration.
2320 + *
2321 + * Covers non-ISO-8601 dates (#465) and WP locales in inLanguage, which must
2322 + * be a BCP-47 tag — en-US, not en_US (#473). Walks nested nodes so values
2323 + * inside author/publisher/@graph entries are covered too.
2324 + *
2325 + * Also decodes HTML entities in plain-text properties. Schema_Builder
2326 + * stored the block editor's `&` as-is until 2.10.0, and nothing
2327 + * decodes JSON-LD downstream, so every deployed node built from post text
2328 + * published the entity literally.
2329 + *
2330 + * @since 1.16.0
2331 + * @since 2.10.0 Decodes entities in plain-text properties.
2332 + *
2333 + * @param array $schema Decoded schema data.
2334 + * @return array Normalised schema.
2335 + */
2336 + private function normalize_stored_schema(array $schema): array {
2337 + static $date_keys = [
2338 + 'datePublished', 'dateModified', 'dateCreated', 'uploadDate',
2339 + 'startDate', 'endDate', 'validFrom', 'validThrough', 'expires',
2340 + ];
2341 +
2342 + // Plain text in schema.org. Answer/HowToStep `text` is deliberately
2343 + // absent: Google reads Answer.text as HTML, where an entity is correct
2344 + // and decoding `<` would turn escaped text into live markup.
2345 + static $text_keys = [
2346 + 'name', 'headline', 'alternativeHeadline', 'description',
2347 + 'reviewBody', 'about', 'abstract', 'caption',
2348 + ];
2349 +
2350 + foreach ($schema as $key => $value) {
2351 + if (is_array($value)) {
2352 + $schema[$key] = $this->normalize_stored_schema($value);
2353 + continue;
2354 + }
2355 +
2356 + if ('inLanguage' === $key && is_string($value) && '' !== $value) {
2357 + $schema[$key] = str_replace('_', '-', $value);
2358 + continue;
2359 + }
2360 +
2361 + // Decode only: a snapshot already truncated with an ellipsis must
2362 + // keep it, which the full Seo_Text::normalize_schema_text() would
2363 + // strip as an excerpt marker.
2364 + if (in_array($key, $text_keys, true) && is_string($value) && '' !== $value) {
2365 + $schema[$key] = \ThinkRank\Core\Seo_Text::decode_schema_entities($value);
2366 + continue;
2367 + }
2368 +
2369 + if (!in_array($key, $date_keys, true) || !is_string($value) || '' === $value) {
2370 + continue;
2371 + }
2372 +
2373 + // Already ISO 8601 — leave it alone.
2374 + if (preg_match('/^\d{4}-\d{2}-\d{2}T/', $value)) {
2375 + continue;
2376 + }
2377 +
2378 + $timestamp = strtotime($value);
2379 +
2380 + if (false !== $timestamp) {
2381 + $schema[$key] = (string) wp_date('c', $timestamp);
2382 + }
2383 + }
2384 +
2385 + return $schema;
2386 + }
2387 +
2388 + /**
1676 2389 * Clean up duplicate schemas in database
1677 2390 *
1678 2391 * @since 1.0.0
1679 2392 *
@@ -1687,18 +2400,18 @@
1687 2400 $table_name = $wpdb->prefix . 'thinkrank_seo_schema';
1688 2401
1689 2402 if (null === $context_id) {
1690 2403 // Clean up duplicates for NULL context_id
1691 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Schema cleanup requires direct database access
2404 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema cleanup requires direct database access
1692 2405 $sql = sprintf(
1693 2406 '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 2407 $table_name,
1695 2408 $table_name
1696 2409 );
1697 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Schema cleanup requires direct database access
2410 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema cleanup requires direct database access
1698 2411 $deleted = $wpdb->query(
1699 2412 $wpdb->prepare(
1700 - // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- SQL is properly prepared with placeholders
2413 + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
1701 2414 $sql,
1702 2415 $context_type,
1703 2416 $context_type
1704 2417 )
@@ -1704,18 +2417,18 @@
1704 2417 )
1705 2418 );
1706 2419 } else {
1707 2420 // Clean up duplicates for specific context_id
1708 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Schema cleanup requires direct database access
2421 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema cleanup requires direct database access
1709 2422 $sql = sprintf(
1710 2423 '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 2424 $table_name,
1712 2425 $table_name
1713 2426 );
1714 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Schema cleanup requires direct database access
2427 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema cleanup requires direct database access
1715 2428 $deleted = $wpdb->query(
1716 2429 $wpdb->prepare(
1717 - // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- SQL is properly prepared with placeholders
2430 + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
1718 2431 $sql,
1719 2432 $context_type,
1720 2433 $context_id,
1721 2434 $context_type,
@@ -1725,51 +2438,154 @@
1725 2438 }
1726 2439
1727 2440 return $deleted ?: 0;
1728 2441 }
2442 +
1729 2443 /**
1730 - * Delete all existing schemas for a context before storing new ones
2444 + * Deactivate deployed schema rows for the given types.
1731 2445 *
2446 + * Deployment was insert-only, so anything ever deployed to a context stayed
2447 + * on the page forever — switching a post's schema type left the old one live
2448 + * and deactivating a saved schema did nothing (#464). Rows are deactivated
2449 + * rather than deleted so a later redeploy can revive them and so there is a
2450 + * trail of what was published.
2451 + *
2452 + * @since 1.16.0
2453 + *
2454 + * @param string $context_type Context type.
2455 + * @param int|null $context_id Context ID.
2456 + * @param string[] $schema_types Types to retire.
2457 + * @return int Number of rows deactivated.
2458 + */
2459 + private function retire_schema_types(string $context_type, ?int $context_id, array $schema_types): int {
2460 + $schema_types = array_values(array_filter(array_map('strval', $schema_types), 'strlen'));
2461 +
2462 + if (empty($schema_types)) {
2463 + return 0;
2464 + }
2465 +
2466 + global $wpdb;
2467 +
2468 + $table_name = $wpdb->prefix . 'thinkrank_seo_schema';
2469 + $placeholders = implode(', ', array_fill(0, count($schema_types), '%s'));
2470 +
2471 + if (null === $context_id) {
2472 + $sql = sprintf(
2473 + 'UPDATE %s SET is_active = 0 WHERE context_type = %%s AND context_id IS NULL AND schema_type IN (%s)',
2474 + $table_name,
2475 + $placeholders
2476 + );
2477 + $args = array_merge([$context_type], $schema_types);
2478 + } else {
2479 + $sql = sprintf(
2480 + 'UPDATE %s SET is_active = 0 WHERE context_type = %%s AND context_id = %%d AND schema_type IN (%s)',
2481 + $table_name,
2482 + $placeholders
2483 + );
2484 + $args = array_merge([$context_type, $context_id], $schema_types);
2485 + }
2486 +
2487 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Retiring deployed schema rows requires direct database access.
2488 + $updated = $wpdb->query(
2489 + $wpdb->prepare(
2490 + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is built from an internal table name and generated placeholders.
2491 + $sql,
2492 + $args
2493 + )
2494 + );
2495 +
2496 + if ($updated && $this->cache_manager) {
2497 + $this->cache_manager->invalidate_context_cache($context_type, $context_id);
2498 + }
2499 +
2500 + return (int) ($updated ?: 0);
2501 + }
2502 +
2503 + /**
2504 + * Retire deployed types that are no longer in the user's Schema Types selection.
2505 + *
2506 + * An empty selection means "auto-detect", so nothing is retired in that case.
2507 + *
2508 + * @since 1.16.0
2509 + *
2510 + * @param string $context_type Context type.
2511 + * @param int|null $context_id Context ID.
2512 + * @param array $enabled_types The user's selected types.
2513 + * @return int Number of rows deactivated.
2514 + */
2515 + private function retire_unselected_schema_types(string $context_type, ?int $context_id, array $enabled_types): int {
2516 + if (empty($enabled_types)) {
2517 + return 0;
2518 + }
2519 +
2520 + $deployed = array_keys($this->get_deployed_schemas($context_type, $context_id));
2521 + $stale = array_diff($deployed, $enabled_types);
2522 +
2523 + return $this->retire_schema_types($context_type, $context_id, $stale);
2524 + }
2525 +
2526 + /**
2527 + * Delete stored schemas for a context before storing new ones.
2528 + *
2529 + * `$schema_types` scopes the delete to the types actually being rewritten.
2530 + * Without it this wiped every type in the context, which silently destroyed
2531 + * deployed schema whenever a caller regenerated a subset — and
2532 + * auto_deploy_schema_on_settings_change() regenerates one type at a time
2533 + * (#454). Passing an empty array keeps the original clear-the-context
2534 + * behaviour for callers that genuinely rewrite everything.
2535 + *
1732 2536 * @since 1.0.0
1733 2537 *
1734 2538 * @param string $context_type Context type
1735 2539 * @param int|null $context_id Context ID
2540 + * @param string[] $schema_types Optional. Limit the delete to these types.
1736 2541 * @return int Number of schemas deleted
1737 2542 */
1738 - private function delete_existing_schemas(string $context_type, ?int $context_id): int {
2543 + private function delete_existing_schemas(string $context_type, ?int $context_id, array $schema_types = []): int {
1739 2544 global $wpdb;
1740 2545
1741 2546 $table_name = $wpdb->prefix . 'thinkrank_seo_schema';
1742 2547
2548 + // Build an optional `AND schema_type IN (…)` clause with one prepared
2549 + // placeholder per type, so the scoping cannot be injected through.
2550 + $type_clause = '';
2551 + $type_values = [];
2552 + $schema_types = array_values(array_filter(array_map('strval', $schema_types), 'strlen'));
2553 + if (!empty($schema_types)) {
2554 + $type_clause = ' AND schema_type IN (' . implode(', ', array_fill(0, count($schema_types), '%s')) . ')';
2555 + $type_values = $schema_types;
2556 + }
2557 +
1743 2558 if (null === $context_id) {
1744 2559 // Delete all schemas for NULL context_id
1745 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Schema deletion requires direct database access
2560 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema deletion requires direct database access
1746 2561 $sql = sprintf(
1747 - 'DELETE FROM %s WHERE context_type = %%s AND context_id IS NULL',
1748 - $table_name
2562 + 'DELETE FROM %s WHERE context_type = %%s AND context_id IS NULL%s',
2563 + $table_name,
2564 + $type_clause
1749 2565 );
1750 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Schema deletion requires direct database access
2566 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema deletion requires direct database access
1751 2567 $deleted = $wpdb->query(
1752 2568 $wpdb->prepare(
1753 - // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- SQL is properly prepared with placeholders
2569 + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
1754 2570 $sql,
1755 - $context_type
2571 + array_merge([$context_type], $type_values)
1756 2572 )
1757 2573 );
1758 2574 } else {
1759 2575 // Delete all schemas for specific context_id
1760 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Schema deletion requires direct database access
2576 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema deletion requires direct database access
1761 2577 $sql = sprintf(
1762 - 'DELETE FROM %s WHERE context_type = %%s AND context_id = %%d',
1763 - $table_name
2578 + 'DELETE FROM %s WHERE context_type = %%s AND context_id = %%d%s',
2579 + $table_name,
2580 + $type_clause
1764 2581 );
1765 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Schema deletion requires direct database access
2582 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema deletion requires direct database access
1766 2583 $deleted = $wpdb->query(
1767 2584 $wpdb->prepare(
1768 - // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- SQL is properly prepared with placeholders
2585 + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
1769 2586 $sql,
1770 - $context_type,
1771 - $context_id
2587 + array_merge([$context_type, $context_id], $type_values)
1772 2588 )
1773 2589 );
1774 2590 }
1775 2591
@@ -1775,14 +2591,8 @@
1775 2591
1776 2592 return $deleted ?: 0;
1777 2593 }
1778 2594
1779 -
1780 -
1781 -
1782 -
1783 -
1784 -
1785 2595 /**
1786 2596 * Get business data from Site Identity Local settings
1787 2597 *
1788 2598 * @return array
@@ -1803,55 +2613,8 @@
1803 2613 'business_hours' => $site_identity_settings['business_hours'] ?? [],
1804 2614 'business_type' => $site_identity_settings['business_type'] ?? 'LocalBusiness'
1805 2615 ];
1806 2616 }
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 2617 public function get_settings(string $context_type, ?int $context_id = null): array {
1855 2618 // Get base settings from parent
1856 2619 $settings = parent::get_settings($context_type, $context_id);
1857 2620
@@ -1906,8 +2669,11 @@
1906 2669 'site_url' => home_url(),
1907 2670 'admin_email' => get_option('admin_email'),
1908 2671 'language' => get_locale(),
1909 2672 'timezone' => get_option('timezone_string'),
2673 + // Read by populate_website_schema(), so the deployed WebSite node
2674 + // carries the same alternateName as the default one (#692).
2675 + 'alternate_name' => $site_identity_settings['alternate_name'] ?? '',
1910 2676 'founded_date' => $site_identity_settings['founded_date'] ?? '',
1911 2677 'founder_name' => $site_identity_settings['founder_name'] ?? '',
1912 2678 'company_type' => $site_identity_settings['company_type'] ?? 'Organization',
1913 2679 // Site Identity assets
@@ -1932,9 +2698,9 @@
1932 2698 'person_address' => $schema_settings['person_address'] ?? '',
1933 2699 'person_birth_date' => $schema_settings['person_birth_date'] ?? '',
1934 2700 'person_nationality' => $schema_settings['person_nationality'] ?? '',
1935 2701 'person_works_for' => $schema_settings['person_works_for'] ?? '',
1936 - 'person_same_as' => $schema_settings['person_same_as'] ?? array(),
2702 + 'person_same_as' => $schema_settings['person_same_as'] ?? [],
1937 2703
1938 2704 // Website schema settings (site-wide)
1939 2705 'website_name' => $schema_settings['website_name'] ?? '',
1940 2706 'website_url' => $schema_settings['website_url'] ?? '',
@@ -1947,8 +2713,11 @@
1947 2713 'organization_social_twitter' => $schema_settings['organization_social_twitter'] ?? '',
1948 2714 'organization_social_linkedin' => $schema_settings['organization_social_linkedin'] ?? '',
1949 2715 'organization_social_instagram' => $schema_settings['organization_social_instagram'] ?? '',
1950 2716 'organization_social_youtube' => $schema_settings['organization_social_youtube'] ?? '',
2717 + 'organization_social_pinterest' => $schema_settings['organization_social_pinterest'] ?? '',
2718 + 'organization_social_whatsapp' => $schema_settings['organization_social_whatsapp'] ?? '',
2719 + 'organization_social_telegram' => $schema_settings['organization_social_telegram'] ?? '',
1951 2720 // Contact point information
1952 2721 'organization_contact_type' => $schema_settings['organization_contact_type'] ?? 'customer service',
1953 2722 'organization_contact_phone' => $schema_settings['organization_contact_phone'] ?? '',
1954 2723 'organization_contact_email' => $schema_settings['organization_contact_email'] ?? '',
@@ -1986,19 +2755,5 @@
1986 2755 'social_profiles' => $social_profiles,
1987 2756 'social_settings' => $social_settings
1988 2757 ];
1989 2758 }
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 2759 }