PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.14.1
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.14.1
2.14.1 2.14.0 2.13.0 2.12.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 All 56 releases
← All changes | includes/seo/class-schema-management-system.php +906 -107 1.27.0 → 2.14.1 View file →
@@ -167,8 +167,20 @@
167 167 'rich_snippets' => ['video', 'video_carousel'],
168 168 'context_types' => ['post', 'page'],
169 169 'priority' => 'medium'
170 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 + ],
171 183 'Recipe' => [
172 184 'name' => 'Recipe',
173 185 'description' => 'Cooking recipes and food preparation',
174 186 'required_properties' => ['name', 'image', 'author', 'datePublished', 'description', 'recipeIngredient', 'recipeInstructions'],
@@ -189,13 +201,43 @@
189 201 'WebPage' => [
190 202 'name' => 'WebPage',
191 203 'description' => 'Individual web pages',
192 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).
193 208 'recommended_properties' => ['description', 'author', 'datePublished', 'breadcrumb'],
194 209 'rich_snippets' => ['webpage', 'breadcrumb'],
195 - 'context_types' => ['page'],
210 + 'context_types' => ['page', 'post'],
196 211 'priority' => 'medium'
197 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 + ],
198 240 'FAQPage' => [
199 241 'name' => 'FAQPage',
200 242 'description' => 'Frequently Asked Questions pages',
201 243 'required_properties' => ['mainEntity'],
@@ -309,8 +351,19 @@
309 351 */
310 352 private ?Schema_Cache_Manager $cache_manager = null;
311 353
312 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 + /**
313 366 * Constructor
314 367 *
315 368 * @since 1.0.0
316 369 */
@@ -326,11 +379,117 @@
326 379 $this->initialize_schema_builder();
327 380
328 381 // Initialize Schema Cache Manager for performance optimization
329 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 + }
330 397 }
331 398
332 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 + /**
333 492 * Initialize Schema Builder
334 493 *
335 494 * @return void
336 495 */
@@ -356,10 +515,20 @@
356 515 require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-schema-cache-manager.php';
357 516 }
358 517
359 518 if (class_exists('ThinkRank\\SEO\\Schema_Cache_Manager')) {
360 - // 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.
361 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 +
362 531 $this->cache_manager = new Schema_Cache_Manager($cache_duration);
363 532 }
364 533 }
365 534
@@ -367,12 +536,19 @@
367 536 * Generate schema markup with comprehensive content analysis integration
368 537 *
369 538 * @since 1.0.0
370 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 + *
371 546 * @param string $context_type Context type
372 547 * @param int|null $context_id Context ID
373 548 * @param array $schema_types Schema types to generate
374 - * @param array $options Generation options
549 + * @param array $options Generation options. Pass `persist => true`
550 + * from explicit write paths only.
375 551 * @return array Comprehensive schema generation results
376 552 */
377 553 public function generate_schema_markup(string $context_type, ?int $context_id, array $schema_types = [], array $options = []): array {
378 554 $generation = [
@@ -443,10 +619,16 @@
443 619
444 620 // Check deployment readiness
445 621 $generation['deployment_ready'] = $this->check_deployment_readiness($generation['validation_results']);
446 622
447 - // Store schema data
448 - $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 + }
449 631
450 632 return $generation;
451 633 }
452 634
@@ -548,8 +730,27 @@
548 730 return $optimization;
549 731 }
550 732
551 733 /**
734 + * Deployed types an authoritative deploy takes off the page.
735 + *
736 + * Everything deployed that the payload left out, except the types the
737 + * caller sent but could not deploy: those failed validation, they were not
738 + * removed by the user, so their live copy stays. Retiring them took the
739 + * site's Organization down behind a success toast (#949).
740 + *
741 + * @since 2.14.1
742 + *
743 + * @param string[] $deployed Types deployed for the context now.
744 + * @param string[] $payload Types in this deploy.
745 + * @param string[] $retain_types Types sent but skipped by validation.
746 + * @return string[] Types to retire.
747 + */
748 + public static function types_to_retire(array $deployed, array $payload, array $retain_types = []): array {
749 + return array_values(array_diff($deployed, $payload, $retain_types));
750 + }
751 +
752 + /**
552 753 * Deploy schema markup with automated implementation
553 754 *
554 755 * @since 1.0.0
555 756 *
@@ -574,8 +775,25 @@
574 775
575 776 // Determine deployment method
576 777 $deployment['deployment_method'] = $this->determine_deployment_method($options);
577 778
779 + // When the caller owns the whole context — the user pressing Deploy, where
780 + // the payload is exactly what the preview showed — anything not in that
781 + // payload should come off the page (#464). Incremental callers such as
782 + // auto_deploy_schema_on_settings_change() pass only the types they
783 + // regenerated, so they must NOT retire the rest.
784 + if (!empty($options['authoritative'])) {
785 + $deployment['retired_schemas'] = $this->retire_schema_types(
786 + $context_type,
787 + $context_id,
788 + self::types_to_retire(
789 + array_keys($this->get_deployed_schemas($context_type, $context_id)),
790 + array_keys($schema_data),
791 + (array) ($options['retain_types'] ?? [])
792 + )
793 + );
794 + }
795 +
578 796 // Deploy each schema
579 797 foreach ($schema_data as $schema_type => $schema) {
580 798 $deploy_result = $this->deploy_single_schema($schema, $schema_type, $deployment['deployment_method'], $context_type, $context_id);
581 799 $deployment['deployed_schemas'][$schema_type] = $deploy_result;
@@ -584,21 +802,91 @@
584 802 // Clean up duplicate schemas
585 803 $this->cleanup_duplicate_schemas($context_type, $context_id);
586 804
587 805 // CACHE INVALIDATION: Clear cache after successful deployment
806 + $cache_invalidated = false;
588 807 if ($this->cache_manager && !empty($deployment['deployed_schemas'])) {
589 808 $this->cache_manager->invalidate_context_cache($context_type, $context_id);
809 + $cache_invalidated = true;
590 810 }
591 811
592 - // Set deployment status based on results
593 - $deployment['cache_status'] = ['cache_updated' => true, 'message' => 'Schema cache updated'];
594 - $deployment['validation_post_deployment'] = ['validation_passed' => true, 'message' => 'Schema deployed successfully'];
595 - $deployment['deployment_status'] = !empty($deployment['deployed_schemas']) ? 'success' : 'failed';
812 + $deployment['cache_status'] = $cache_invalidated
813 + ? ['cache_updated' => true, 'message' => 'Schema cache invalidated']
814 + : ['cache_updated' => false, 'message' => 'No schema cache to invalidate'];
596 815
816 + // Post-deployment verification: read back through the same accessor the
817 + // front end uses, so a row that was written but is not retrievable (wrong
818 + // context, inactive, stale cache) is reported as a failure instead of
819 + // being assumed successful.
820 + $deployment['validation_post_deployment'] = $this->verify_deployment(
821 + $context_type,
822 + $context_id,
823 + array_keys($deployment['deployed_schemas'])
824 + );
825 +
826 + $writes_ok = !empty($deployment['deployed_schemas']);
827 + foreach ($deployment['deployed_schemas'] as $deploy_result) {
828 + if (empty($deploy_result['deployed'])) {
829 + $writes_ok = false;
830 + break;
831 + }
832 + }
833 +
834 + $deployment['deployment_status'] =
835 + ($writes_ok && !empty($deployment['validation_post_deployment']['validation_passed']))
836 + ? 'success'
837 + : 'failed';
838 +
597 839 return $deployment;
598 840 }
599 841
600 842 /**
843 + * Verify deployed schema is retrievable after a deploy.
844 + *
845 + * Reads back through get_deployed_schemas() — the same accessor
846 + * Frontend\SEO_Manager::output_site_schema_markup() uses to emit schema — so
847 + * the check reflects what will actually reach the page rather than only that
848 + * an INSERT returned without error.
849 + *
850 + * @since 1.32.0
851 + *
852 + * @param string $context_type Context type
853 + * @param int|null $context_id Context ID
854 + * @param array $expected_types Schema types that were just deployed
855 + * @return array Validation result
856 + */
857 + private function verify_deployment(string $context_type, ?int $context_id, array $expected_types): array {
858 + if (empty($expected_types)) {
859 + return [
860 + 'validation_passed' => false,
861 + 'message' => 'No schema was deployed',
862 + 'missing_types' => []
863 + ];
864 + }
865 +
866 + $retrieved = $this->get_deployed_schemas($context_type, $context_id);
867 + $missing = array_values(array_diff($expected_types, array_keys($retrieved)));
868 +
869 + if (!empty($missing)) {
870 + return [
871 + 'validation_passed' => false,
872 + 'message' => sprintf(
873 + /* translators: %s: comma-separated list of schema types */
874 + __('Deployed schema could not be read back: %s', 'thinkrank'),
875 + implode(', ', $missing)
876 + ),
877 + 'missing_types' => $missing
878 + ];
879 + }
880 +
881 + return [
882 + 'validation_passed' => true,
883 + 'message' => __('Schema deployed and read back from storage', 'thinkrank'),
884 + 'missing_types' => []
885 + ];
886 + }
887 +
888 + /**
601 889 * Track schema performance and rich snippet appearances
602 890 *
603 891 * @since 1.0.0
604 892 *
@@ -713,9 +1001,11 @@
713 1001 'validation_results' => [],
714 1002 'rich_snippets_preview' => [],
715 1003 'performance_data' => [],
716 1004 'recommendations' => [],
717 - 'enabled' => true
1005 + // Report the real setting. Hardcoding true here told every consumer
1006 + // the feature was on even when the master switch was off (#461).
1007 + 'enabled' => (bool) ($settings['enabled'] ?? true)
718 1008 ];
719 1009
720 1010 // Get enabled schema types
721 1011 $enabled_types = $settings['enabled_schema_types'] ?? [];
@@ -823,9 +1113,9 @@
823 1113 // For site context: only apply site-level schema settings
824 1114 if ($context_type === 'site') {
825 1115 // Add local business schema if enabled
826 1116 if ($options['enable_local_business'] ?? false) {
827 - if (!in_array('LocalBusiness', $enabled_types)) {
1117 + if (!in_array('LocalBusiness', $enabled_types, true)) {
828 1118 $enabled_types[] = 'LocalBusiness';
829 1119 }
830 1120 }
831 1121
@@ -835,9 +1125,9 @@
835 1125 // For post/page context: apply all schema settings (metabox functionality)
836 1126
837 1127 // Add article schema if enabled and context is appropriate
838 1128 if ($options['enable_article_schema'] ?? false) {
839 - if (in_array($context_type, ['post', 'page']) && !in_array('Article', $enabled_types)) {
1129 + if (in_array($context_type, ['post', 'page'], true) && !in_array('Article', $enabled_types, true)) {
840 1130 $enabled_types[] = 'Article';
841 1131 }
842 1132 }
843 1133
@@ -842,9 +1132,9 @@
842 1132 }
843 1133
844 1134 // Add FAQ schema if enabled
845 1135 if ($options['enable_faq_schema'] ?? false) {
846 - if (!in_array('FAQPage', $enabled_types)) {
1136 + if (!in_array('FAQPage', $enabled_types, true)) {
847 1137 $enabled_types[] = 'FAQPage';
848 1138 }
849 1139 }
850 1140
@@ -849,9 +1139,9 @@
849 1139 }
850 1140
851 1141 // Add How-To schema if enabled
852 1142 if ($options['enable_howto_schema'] ?? false) {
853 - if (!in_array('HowTo', $enabled_types)) {
1143 + if (!in_array('HowTo', $enabled_types, true)) {
854 1144 $enabled_types[] = 'HowTo';
855 1145 }
856 1146 }
857 1147
@@ -856,9 +1146,9 @@
856 1146 }
857 1147
858 1148 // Add product schema if enabled and context is appropriate
859 1149 if ($options['enable_product_schema'] ?? false) {
860 - if ($context_type === 'product' && !in_array('Product', $enabled_types)) {
1150 + if ($context_type === 'product' && !in_array('Product', $enabled_types, true)) {
861 1151 $enabled_types[] = 'Product';
862 1152 }
863 1153 }
864 1154
@@ -863,9 +1153,9 @@
863 1153 }
864 1154
865 1155 // Add local business schema if enabled
866 1156 if ($options['enable_local_business'] ?? false) {
867 - if (!in_array('LocalBusiness', $enabled_types)) {
1157 + if (!in_array('LocalBusiness', $enabled_types, true)) {
868 1158 $enabled_types[] = 'LocalBusiness';
869 1159 }
870 1160 }
871 1161
@@ -886,9 +1176,9 @@
886 1176 // For site context: only apply site-level schema settings
887 1177 if ($context_type === 'site') {
888 1178 // Add local business schema if enabled
889 1179 if ($settings['enable_local_business'] ?? false) {
890 - if (!in_array('LocalBusiness', $enabled_types)) {
1180 + if (!in_array('LocalBusiness', $enabled_types, true)) {
891 1181 $enabled_types[] = 'LocalBusiness';
892 1182 }
893 1183 }
894 1184
@@ -893,9 +1183,9 @@
893 1183 }
894 1184
895 1185 // Add breadcrumbs schema if enabled (site-wide feature)
896 1186 if ($settings['enable_breadcrumbs_schema'] ?? false) {
897 - if (!in_array('BreadcrumbList', $enabled_types)) {
1187 + if (!in_array('BreadcrumbList', $enabled_types, true)) {
898 1188 $enabled_types[] = 'BreadcrumbList';
899 1189 }
900 1190 }
901 1191
@@ -905,9 +1195,9 @@
905 1195 // For post/page context: apply all schema settings (metabox functionality)
906 1196
907 1197 // Add article schema if enabled and context is appropriate
908 1198 if ($settings['enable_article_schema'] ?? false) {
909 - if (in_array($context_type, ['post', 'page']) && !in_array('Article', $enabled_types)) {
1199 + if (in_array($context_type, ['post', 'page'], true) && !in_array('Article', $enabled_types, true)) {
910 1200 $enabled_types[] = 'Article';
911 1201 }
912 1202 }
913 1203
@@ -912,9 +1202,9 @@
912 1202 }
913 1203
914 1204 // Add FAQ schema if enabled
915 1205 if ($settings['enable_faq_schema'] ?? false) {
916 - if (!in_array('FAQPage', $enabled_types)) {
1206 + if (!in_array('FAQPage', $enabled_types, true)) {
917 1207 $enabled_types[] = 'FAQPage';
918 1208 }
919 1209 }
920 1210
@@ -919,9 +1209,9 @@
919 1209 }
920 1210
921 1211 // Add How-To schema if enabled
922 1212 if ($settings['enable_howto_schema'] ?? false) {
923 - if (!in_array('HowTo', $enabled_types)) {
1213 + if (!in_array('HowTo', $enabled_types, true)) {
924 1214 $enabled_types[] = 'HowTo';
925 1215 }
926 1216 }
927 1217
@@ -926,9 +1216,9 @@
926 1216 }
927 1217
928 1218 // Add product schema if enabled and context is appropriate
929 1219 if ($settings['enable_product_schema'] ?? false) {
930 - if ($context_type === 'product' && !in_array('Product', $enabled_types)) {
1220 + if ($context_type === 'product' && !in_array('Product', $enabled_types, true)) {
931 1221 $enabled_types[] = 'Product';
932 1222 }
933 1223 }
934 1224
@@ -933,9 +1223,9 @@
933 1223 }
934 1224
935 1225 // Add local business schema if enabled
936 1226 if ($settings['enable_local_business'] ?? false) {
937 - if (!in_array('LocalBusiness', $enabled_types)) {
1227 + if (!in_array('LocalBusiness', $enabled_types, true)) {
938 1228 $enabled_types[] = 'LocalBusiness';
939 1229 }
940 1230 }
941 1231
@@ -969,8 +1259,47 @@
969 1259 return home_url('/wp-content/plugins/thinkrank/assets/images/default-logo.jpg');
970 1260 }
971 1261
972 1262 /**
1263 + * Schema keys outside the shared config defaults.
1264 + *
1265 + * @since 2.0.1
1266 + *
1267 + * @return string[]
1268 + */
1269 + protected function additional_setting_keys(): array {
1270 + return [
1271 + 'enable_article_schema', 'enable_product_schema',
1272 + 'enable_faq_schema', 'enable_howto_schema',
1273 + ];
1274 + }
1275 +
1276 + /**
1277 + * Per-entity schema fields are an open set.
1278 + *
1279 + * Each schema type the UI can edit contributes its own field family —
1280 + * organization_*, person_*, website_*, business_*, software_*, howto_* —
1281 + * and a new type adds another. The families this manager owns are matched
1282 + * rather than enumerated, so adding a form does not silently start
1283 + * dropping its fields (#452).
1284 + *
1285 + * @since 2.0.1
1286 + *
1287 + * @return string[]
1288 + */
1289 + protected function dynamic_setting_key_patterns(): array {
1290 + return [
1291 + '/^organization_[a-z0-9_]+$/',
1292 + '/^person_[a-z0-9_]+$/',
1293 + '/^website_[a-z0-9_]+$/',
1294 + '/^business_[a-z0-9_]+$/',
1295 + '/^software_[a-z0-9_]+$/',
1296 + '/^howto_[a-z0-9_]+$/',
1297 + '/^product_[a-z0-9_]+$/',
1298 + ];
1299 + }
1300 +
1301 + /**
973 1302 * Get default settings for a context type (implements interface)
974 1303 *
975 1304 * @since 1.0.0
976 1305 *
@@ -1016,9 +1345,9 @@
1016 1345 $this->cache_manager->invalidate_all_cache();
1017 1346 }
1018 1347
1019 1348 // AUTO-DEPLOY: Automatically regenerate and deploy schema when settings change
1020 - if ($success && !empty($settings['auto_deploy'])) {
1349 + if ($success && self::should_auto_deploy($settings, $this->get_settings($context_type, $context_id))) {
1021 1350 $this->auto_deploy_schema_on_settings_change($context_type, $context_id, $settings);
1022 1351 }
1023 1352
1024 1353 return $success;
@@ -1023,9 +1352,35 @@
1023 1352
1024 1353 return $success;
1025 1354 }
1026 1355
1356 +
1027 1357 /**
1358 + * Whether a save should redeploy the schema it changed.
1359 + *
1360 + * `auto_deploy` is a stored setting (on by default), not something a save
1361 + * restates. Reading it off the incoming patch meant a partial save — which
1362 + * is what the admin screen sends, one field at a time — skipped
1363 + * auto-deploy on a site that had it switched on, and the deployed snapshot
1364 + * the front end serves kept the name, logo and sameAs it was deployed
1365 + * with, however often the user saved (#904, the gate #12 left in place).
1366 + *
1367 + * A patch that does carry the key still wins, so a caller can deploy or
1368 + * hold deliberately.
1369 + *
1370 + * @param array $patch Settings being saved
1371 + * @param array $stored Settings as stored, after the save
1372 + * @return bool
1373 + */
1374 + public static function should_auto_deploy(array $patch, array $stored): bool {
1375 + if (array_key_exists('auto_deploy', $patch)) {
1376 + return !empty($patch['auto_deploy']);
1377 + }
1378 +
1379 + return !empty($stored['auto_deploy']);
1380 + }
1381 +
1382 + /**
1028 1383 * Auto-deploy schema when settings change
1029 1384 *
1030 1385 * Automatically regenerates and deploys schema markup when organization or other
1031 1386 * schema settings are modified, ensuring the frontend output stays in sync.
@@ -1062,38 +1417,59 @@
1062 1417 if ($this->has_person_settings_changed($settings)) {
1063 1418 $schema_types_to_regenerate[] = 'Person';
1064 1419 }
1065 1420
1421 + // Honour the user's Schema Types selection. Without this the payload
1422 + // shape alone decided what shipped, so every save deployed all four
1423 + // types — including ones the user had explicitly deselected (#461).
1424 + // An empty selection means "auto", so only filter when one is set.
1425 + $enabled_types = $settings['enabled_schema_types'] ?? $this->get_settings($context_type, $context_id)['enabled_schema_types'] ?? [];
1426 +
1427 + if (!empty($enabled_types) && is_array($enabled_types)) {
1428 + $schema_types_to_regenerate = array_values(
1429 + array_intersect($schema_types_to_regenerate, $enabled_types)
1430 + );
1431 + }
1432 +
1433 + // Types that were deployed but are no longer wanted must come back off
1434 + // the page — deployment used to be additive-only (#464).
1435 + $this->retire_unselected_schema_types($context_type, $context_id, $enabled_types);
1436 +
1066 1437 // If no schema types need regeneration, return early
1067 1438 if (empty($schema_types_to_regenerate)) {
1068 1439 return;
1069 1440 }
1070 1441
1071 - // Generate and deploy each schema type
1072 - foreach ($schema_types_to_regenerate as $schema_type) {
1073 - try {
1074 - // Generate schema using generate_schema_markup
1075 - $generation_result = $this->generate_schema_markup(
1076 - $context_type,
1077 - $context_id,
1078 - [$schema_type]
1079 - );
1442 + // Generate every affected type in ONE call. Generating them one at a
1443 + // time re-entered store_schema_data() per type, and each pass replaced
1444 + // the rows written by the previous one, so only the last type survived
1445 + // (#454). One batch also means one delete and one cache flush.
1446 + try {
1447 + $generation_result = $this->generate_schema_markup(
1448 + $context_type,
1449 + $context_id,
1450 + $schema_types_to_regenerate
1451 + );
1080 1452
1081 - // Deploy if generation was successful
1082 - if (!empty($generation_result['generated_schemas'][$schema_type]) && $generation_result['deployment_ready']) {
1083 - $this->deploy_schema_markup(
1084 - $context_type,
1085 - $context_id,
1086 - [$schema_type => $generation_result['generated_schemas'][$schema_type]]
1087 - );
1453 + $deployable = [];
1454 + foreach ($schema_types_to_regenerate as $schema_type) {
1455 + // Only deploy what validated — see #470.
1456 + if (!empty($generation_result['generated_schemas'][$schema_type])
1457 + && !empty($generation_result['validation_results'][$schema_type]['is_valid'])
1458 + ) {
1459 + $deployable[$schema_type] = $generation_result['generated_schemas'][$schema_type];
1088 1460 }
1089 - } catch (\Exception $e) {
1090 - // Log error but don't fail the settings save
1091 - if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
1092 - // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
1093 - error_log('ThinkRank: Auto-deploy failed for ' . $schema_type . ': ' . $e->getMessage());
1094 - }
1095 1461 }
1462 +
1463 + if (!empty($deployable)) {
1464 + $this->deploy_schema_markup($context_type, $context_id, $deployable);
1465 + }
1466 + } catch (\Exception $e) {
1467 + // Log error but don't fail the settings save
1468 + if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
1469 + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
1470 + error_log('ThinkRank: Auto-deploy failed for ' . implode(', ', $schema_types_to_regenerate) . ': ' . $e->getMessage());
1471 + }
1096 1472 }
1097 1473 }
1098 1474
1099 1475 /**
@@ -1131,9 +1507,15 @@
1131 1507 * @param array $settings Updated settings
1132 1508 * @return bool True if website settings changed
1133 1509 */
1134 1510 private function has_website_settings_changed(array $settings): bool {
1135 - $website_keys = ['site_name', 'site_description', 'site_url'];
1511 + // These are the keys the Website tab actually stores. It previously
1512 + // looked for site_name/site_description/site_url, which belong to Site
1513 + // Identity and never appear in a schema settings payload — so WebSite
1514 + // schema never auto-deployed no matter what was edited (#455).
1515 + $website_keys = [
1516 + 'website_name', 'website_url', 'website_description', 'website_author',
1517 + ];
1136 1518
1137 1519 foreach ($website_keys as $key) {
1138 1520 if (isset($settings[$key])) {
1139 1521 return true;
@@ -1151,11 +1533,20 @@
1151 1533 * @param array $settings Updated settings
1152 1534 * @return bool True if business settings changed
1153 1535 */
1154 1536 private function has_business_settings_changed(array $settings): bool {
1537 + // Only the keys this manager actually stores. business_name/address/
1538 + // phone/hours live in the site_identity category and never reach a
1539 + // schema settings save, so keying off them meant LocalBusiness never
1540 + // auto-deployed (#455). Edits to those fields refresh LocalBusiness
1541 + // through the Site Identity save path instead — see
1542 + // refresh_schema_for_foreign_settings().
1155 1543 $business_keys = [
1156 - 'business_name', 'business_type', 'business_address', 'business_phone',
1157 - 'business_hours', 'business_price_range'
1544 + 'enable_local_business',
1545 + 'business_price_range',
1546 + 'business_geo_latitude',
1547 + 'business_geo_longitude',
1548 + 'business_opening_hours',
1158 1549 ];
1159 1550
1160 1551 foreach ($business_keys as $key) {
1161 1552 if (isset($settings[$key])) {
@@ -1199,8 +1590,29 @@
1199 1590 $detected_types = [];
1200 1591
1201 1592 switch ($context_type) {
1202 1593 case 'site':
1594 + // The admin's Schema Types selection is the answer to "what
1595 + // does this site need"; detection is only the fallback for an
1596 + // install that has not chosen yet (#456).
1597 + $settings = $this->get_settings($context_type, $context_id);
1598 + $enabled = array_values(array_filter(
1599 + array_map('strval', (array) ($settings['enabled_schema_types'] ?? [])),
1600 + 'strlen'
1601 + ));
1602 +
1603 + // Drop stale names the factory no longer registers rather than
1604 + // handing them to the builder to silently skip.
1605 + $enabled = array_values(array_filter(
1606 + $enabled,
1607 + fn($type) => isset($this->schema_types[$type])
1608 + ));
1609 +
1610 + if (!empty($enabled)) {
1611 + $detected_types = $enabled;
1612 + break;
1613 + }
1614 +
1203 1615 $detected_types = ['Organization'];
1204 1616 // Check if it's a local business
1205 1617 if ($this->is_local_business()) {
1206 1618 $detected_types[] = 'LocalBusiness';
@@ -1259,24 +1671,39 @@
1259 1671 'business_data' => $this->get_business_data_from_local_seo(),
1260 1672 'site_data' => $this->get_site_data_for_schema(),
1261 1673 'social_data' => $this->get_social_data_for_schema()
1262 1674 ];
1263 - } elseif ($context_id && in_array($context_type, ['post', 'page', 'product'])) {
1675 + } elseif ($context_id && in_array($context_type, ['post', 'page', 'product'], true)) {
1264 1676 // Post/page/product data
1265 1677 $post = get_post($context_id);
1266 1678 if ($post) {
1679 + // Resolve the featured image's URL to its ID here, where the ID
1680 + // is in hand, so the schema builder does not query for an
1681 + // attachment it was just given (#847). Offered as a hint rather
1682 + // than asserted: `post_thumbnail_url` can swap the URL for one
1683 + // the featured image does not own.
1684 + $thumbnail_url = get_the_post_thumbnail_url($post->ID, 'full');
1685 +
1686 + if ($thumbnail_url) {
1687 + Attachment_Lookup::id_from_url((string) $thumbnail_url, (int) get_post_thumbnail_id($post->ID));
1688 + }
1689 +
1267 1690 $content_data = [
1268 1691 'title' => $post->post_title,
1269 1692 'url' => get_permalink($post->ID),
1270 - 'excerpt' => $post->post_excerpt ?: wp_trim_words($post->post_content, 30),
1693 + 'excerpt' => $post->post_excerpt ?: \ThinkRank\Core\Seo_Text::trim_words($post->post_content, 30),
1271 1694 'content' => $post->post_content,
1272 1695 'author' => [
1273 1696 'name' => get_the_author_meta('display_name', $post->post_author),
1274 1697 'url' => get_author_posts_url($post->post_author)
1275 1698 ],
1276 - 'date' => $post->post_date,
1277 - 'modified' => $post->post_modified,
1278 - 'image' => get_the_post_thumbnail_url($post->ID, 'full'),
1699 + // ISO 8601 with offset. post_date/post_modified are raw
1700 + // MySQL columns in site-local time with no timezone, which
1701 + // Google rejects as "Invalid value in field datePublished"
1702 + // and drops the Article rich result (#465).
1703 + 'date' => get_the_date('c', $post),
1704 + 'modified' => get_the_modified_date('c', $post),
1705 + 'image' => $thumbnail_url,
1279 1706 'focus_keywords' => Focus_Keywords::get($post->ID),
1280 1707 'business_data' => $this->get_business_data_from_local_seo(),
1281 1708 'site_data' => $this->get_site_data_for_schema(),
1282 1709 'social_data' => $this->get_social_data_for_schema()
@@ -1370,10 +1797,16 @@
1370 1797 global $wpdb;
1371 1798
1372 1799 $table_name = $wpdb->prefix . 'thinkrank_seo_schema';
1373 1800
1374 - // First, delete all existing schemas for this context to ensure clean storage
1375 - $this->delete_existing_schemas($context_type, $context_id);
1801 + // Replace only the types in this batch. Clearing the whole context
1802 + // destroyed types the caller never asked about — and callers do
1803 + // regenerate a subset, one type at a time (#454).
1804 + $generated_types = array_keys($generation['generated_schemas'] ?? []);
1805 + if (empty($generated_types)) {
1806 + return false;
1807 + }
1808 + $this->delete_existing_schemas($context_type, $context_id, $generated_types);
1376 1809
1377 1810 foreach ($generation['generated_schemas'] as $schema_type => $schema_data) {
1378 1811 // Prepare schema data with validation status embedded
1379 1812 $schema_data_with_validation = $schema_data;
@@ -1389,9 +1822,13 @@
1389 1822 'context_id' => $context_id,
1390 1823 'schema_type' => $schema_type,
1391 1824 'schema_data' => wp_json_encode($schema_data_with_validation),
1392 1825 'validation_status' => $generation['validation_results'][$schema_type]['is_valid'] ? 'valid' : 'invalid',
1393 - 'is_active' => $generation['deployment_ready'] ? 1 : 0
1826 + // Per-type, not batch-wide. deployment_ready is only true when
1827 + // EVERY type in the batch validated, so one invalid type (a site
1828 + // with no Business Info makes LocalBusiness invalid) deactivated
1829 + // all the valid ones alongside it (#470).
1830 + 'is_active' => !empty($generation['validation_results'][$schema_type]['is_valid']) ? 1 : 0
1394 1831 ];
1395 1832
1396 1833 // Insert new schema (existing ones were already deleted)
1397 1834 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema insertion requires direct database access
@@ -1659,12 +2096,10 @@
1659 2096
1660 2097 /**
1661 2098 * Get deployed schemas for frontend integration
1662 2099 *
1663 - * PERFORMANCE OPTIMIZED: This method now uses:
1664 - * 1. Schema caching layer (90% reduction in database queries)
1665 - * 2. Window function approach instead of correlated subquery (80-90% query performance improvement)
1666 - * 3. Composite index: idx_context_schema_active
2100 + * Returns the newest active, deployed row of each schema type for the
2101 + * context. Results are cached per context (see Schema_Cache_Manager).
1667 2102 *
1668 2103 * @since 1.0.0
1669 2104 *
1670 2105 * @param string $context_type Context type
@@ -1687,47 +2122,59 @@
1687 2122
1688 2123 // Use existing seo_schema table
1689 2124 $table_name = $wpdb->prefix . 'thinkrank_seo_schema';
1690 2125
1691 - // OPTIMIZED QUERY: Use window function approach to eliminate correlated subquery
1692 - // This leverages the new composite index: idx_context_schema_active (context_type, schema_type, is_active, created_at DESC)
2126 + // The newest row per type used to be picked with ROW_NUMBER() OVER
2127 + // (PARTITION BY schema_type ...). Window functions need MySQL 8.0 /
2128 + // MariaDB 10.2, and WordPress still runs on MySQL 5.7, where that is
2129 + // a syntax error on every page view and no deployed schema is ever
2130 + // output. A row is the newest of its type when no other row of the
2131 + // same context and type outranks it, so NOT EXISTS keeps the
2132 + // greatest-per-group in the database, and schema_data — JSON, and
2133 + // large — is only transferred for the rows that are output.
2134 + //
2135 + // `<=>` is NULL-safe equality: the site context stores context_id
2136 + // as NULL, and `n.context_id = s.context_id` is never true for it.
2137 + // schema_id breaks a same-second tie, which the window function
2138 + // left to chance.
2139 + $args = [$context_type];
1693 2140 if (null === $context_id) {
1694 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema retrieval requires direct database access
1695 - $sql = sprintf(
1696 - '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',
1697 - $table_name
1698 - );
1699 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema retrieval requires direct database access
1700 - $deployed_schemas = $wpdb->get_results(
1701 - $wpdb->prepare(
1702 - // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
1703 - $sql,
1704 - $context_type
1705 - ),
1706 - ARRAY_A
1707 - );
2141 + $context_where = 's.context_id IS NULL';
1708 2142 } else {
1709 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema retrieval requires direct database access
1710 - $sql = sprintf(
1711 - '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',
1712 - $table_name
1713 - );
1714 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema retrieval requires direct database access
1715 - $deployed_schemas = $wpdb->get_results(
1716 - $wpdb->prepare(
1717 - // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
1718 - $sql,
1719 - $context_type,
1720 - $context_id
1721 - ),
1722 - ARRAY_A
1723 - );
2143 + $context_where = 's.context_id = %d';
2144 + $args[] = $context_id;
1724 2145 }
1725 2146
1726 - if (empty($deployed_schemas)) {
1727 - return [];
1728 - }
2147 + $sql = sprintf(
2148 + 'SELECT s.schema_type, s.schema_data FROM %1$s s'
2149 + . ' WHERE s.context_type = %%s AND %2$s AND s.is_active = 1 AND s.validation_status IN (\'deployed\', \'valid\')'
2150 + . ' AND NOT EXISTS ('
2151 + . 'SELECT 1 FROM %1$s n'
2152 + . ' WHERE n.context_type = s.context_type AND n.context_id <=> s.context_id AND n.schema_type = s.schema_type'
2153 + . ' AND n.is_active = 1 AND n.validation_status IN (\'deployed\', \'valid\')'
2154 + . ' AND (n.created_at > s.created_at OR (n.created_at = s.created_at AND n.schema_id > s.schema_id))'
2155 + . ')'
2156 + . ' ORDER BY s.schema_type',
2157 + $table_name,
2158 + $context_where
2159 + );
1729 2160
2161 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema retrieval requires direct database access
2162 + $deployed_schemas = $wpdb->get_results(
2163 + $wpdb->prepare(
2164 + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
2165 + $sql,
2166 + ...$args
2167 + ),
2168 + ARRAY_A
2169 + );
2170 +
2171 + // Deliberately no early return on an empty result: it has to reach the
2172 + // cache write below. Most URLs have no deployed schema, so gating the
2173 + // write on a non-empty result made the majority of front-end requests
2174 + // permanent cache misses, re-running the query on every pageview (#392).
2175 + $deployed_schemas = $deployed_schemas ?: [];
2176 +
1730 2177 // Process schemas for return
1731 2178 $processed_schemas = [];
1732 2179 foreach ($deployed_schemas as $deployed_schema) {
1733 2180 $schema_data = json_decode($deployed_schema['schema_data'], true);
@@ -1738,8 +2185,26 @@
1738 2185 if (isset($schema_data['_validation'])) {
1739 2186 unset($schema_data['_validation']);
1740 2187 }
1741 2188
2189 + // Deployed schema is a snapshot, so rows written before #465
2190 + // still carry raw MySQL datetimes. Normalise on read so the
2191 + // fix reaches existing sites without a migration.
2192 + $schema_data = $this->normalize_stored_schema($schema_data);
2193 +
2194 + // The permalink was frozen at deploy time, so schema deployed
2195 + // while a post was a draft advertised "?p=123" as both url and
2196 + // mainEntityOfPage forever — contradicting the node's own @id
2197 + // and the canonical (#470). Resolve it live instead.
2198 + $schema_data = $this->refresh_schema_permalink($schema_data, $context_type, $context_id);
2199 +
2200 + // schema.org types `sameAs`, `url`, `logo` and `image` as URLs,
2201 + // but the form stored whatever was typed, so free text entered
2202 + // in a social-profile field shipped as a sameAs member and made
2203 + // the whole entity invalid (#480). Drop bad values on read, so
2204 + // existing sites stop emitting them without a migration.
2205 + $schema_data = $this->filter_entity_urls($schema_data);
2206 +
1742 2207 $processed_schemas[$schema_type] = [
1743 2208 'data' => $schema_data,
1744 2209 'method' => 'json_ld', // Default method
1745 2210 'type' => $schema_type
@@ -1746,10 +2211,13 @@
1746 2211 ];
1747 2212 }
1748 2213 }
1749 2214
1750 - // CACHE LAYER: Store result in cache for future requests
1751 - if ($this->cache_manager && !empty($processed_schemas)) {
2215 + // CACHE LAYER: Store result in cache for future requests — including
2216 + // an empty one. Cache_Manager::set() wraps the payload in a metadata
2217 + // envelope, so an empty result is still stored as a truthy value and
2218 + // reads back as a hit rather than a miss (#392).
2219 + if ($this->cache_manager) {
1752 2220 $cache_key = $this->cache_manager->generate_deployed_schemas_key($context_type, $context_id);
1753 2221 $this->cache_manager->set($cache_key, $processed_schemas);
1754 2222 }
1755 2223
@@ -1756,8 +2224,233 @@
1756 2224 return $processed_schemas;
1757 2225 }
1758 2226
1759 2227 /**
2228 + * Properties schema.org defines as URLs.
2229 + *
2230 + * @since 2.0.2
2231 + * @var string[]
2232 + */
2233 + private const URL_PROPERTIES = ['sameAs', 'url', 'logo', 'image'];
2234 +
2235 + /**
2236 + * Whether a value is a URL safe to publish in structured data.
2237 + *
2238 + * @since 2.0.2
2239 + *
2240 + * @param mixed $url Candidate value.
2241 + * @return bool
2242 + */
2243 + private function is_publishable_url($url): bool {
2244 + if (!is_string($url) || '' === trim($url)) {
2245 + return false;
2246 + }
2247 +
2248 + if (!filter_var($url, FILTER_VALIDATE_URL)) {
2249 + return false;
2250 + }
2251 +
2252 + $scheme = wp_parse_url($url, PHP_URL_SCHEME);
2253 +
2254 + return in_array(strtolower((string) $scheme), ['http', 'https'], true);
2255 + }
2256 +
2257 + /**
2258 + * Drop values that are not URLs from URL-typed properties.
2259 + *
2260 + * An absent property is valid; one holding free text is not, and it can
2261 + * invalidate the entity around it. Nested objects (`logo` and `image` are
2262 + * frequently ImageObjects) are walked so a bad `url` inside one is caught
2263 + * too. A property left with nothing is removed rather than emitted empty.
2264 + *
2265 + * @since 2.0.2
2266 + *
2267 + * @param array $schema Decoded schema data.
2268 + * @return array Schema carrying only publishable URLs.
2269 + */
2270 + private function filter_entity_urls(array $schema): array {
2271 + foreach ($schema as $key => $value) {
2272 + if (is_array($value) && !in_array($key, self::URL_PROPERTIES, true)) {
2273 + $schema[$key] = $this->filter_entity_urls($value);
2274 + continue;
2275 + }
2276 +
2277 + if (!in_array($key, self::URL_PROPERTIES, true)) {
2278 + continue;
2279 + }
2280 +
2281 + // A nested object (ImageObject and friends) carries its own url.
2282 + if (is_array($value) && isset($value['@type'])) {
2283 + $schema[$key] = $this->filter_entity_urls($value);
2284 + continue;
2285 + }
2286 +
2287 + if (is_array($value)) {
2288 + $kept = [];
2289 +
2290 + foreach ($value as $item) {
2291 + if (is_array($item)) {
2292 + $kept[] = $this->filter_entity_urls($item);
2293 + } elseif ($this->is_publishable_url($item)) {
2294 + $kept[] = $item;
2295 + }
2296 + }
2297 +
2298 + if ([] === $kept) {
2299 + unset($schema[$key]);
2300 + } else {
2301 + $schema[$key] = array_values($kept);
2302 + }
2303 +
2304 + continue;
2305 + }
2306 +
2307 + if (!$this->is_publishable_url($value)) {
2308 + unset($schema[$key]);
2309 + }
2310 + }
2311 +
2312 + return $schema;
2313 + }
2314 +
2315 + /**
2316 + * Schema types whose `url` identifies the entity, not the page.
2317 + *
2318 + * On a Person or an Organization, `url` is that entity's own website, so
2319 + * overwriting it with the permalink of whichever post the schema happens to
2320 + * be deployed on is simply wrong. It also breaks graph assembly: the site
2321 + * identity emits the same entity with its real `url`, and once the two
2322 + * copies disagree they can no longer be recognised as one entity (#479).
2323 + *
2324 + * @since 2.0.2
2325 + * @var string[]
2326 + */
2327 + private const ENTITY_URL_TYPES = ['Person', 'Organization', 'LocalBusiness'];
2328 +
2329 + /**
2330 + * Replace a stored permalink snapshot with the post's live permalink.
2331 + *
2332 + * Only touches `url` and `mainEntityOfPage`, and only for post-like
2333 + * contexts where a permalink actually exists. Identity entities are
2334 + * exempt from the `url` rewrite — see self::ENTITY_URL_TYPES.
2335 + *
2336 + * @since 1.16.0
2337 + *
2338 + * @param array $schema Decoded schema data.
2339 + * @param string $context_type Context type.
2340 + * @param int|null $context_id Context ID.
2341 + * @return array Schema with a current permalink.
2342 + */
2343 + private function refresh_schema_permalink(array $schema, string $context_type, ?int $context_id): array {
2344 + if ('site' === $context_type || empty($context_id)) {
2345 + return $schema;
2346 + }
2347 +
2348 + $permalink = get_permalink($context_id);
2349 +
2350 + if (!$permalink) {
2351 + return $schema;
2352 + }
2353 +
2354 + $type = $schema['@type'] ?? '';
2355 + $type = is_array($type) ? reset($type) : $type;
2356 + // A LocalBusiness is deployed under the subtype the site chose, so the
2357 + // exemption has to cover every subtype, not only the literal root.
2358 + $is_entity = in_array((string) $type, self::ENTITY_URL_TYPES, true)
2359 + || \ThinkRank\Config\Local_Business_Types_Config::is_local_business($type);
2360 +
2361 + if (isset($schema['url']) && !$is_entity) {
2362 + $schema['url'] = $permalink;
2363 + }
2364 +
2365 + if (isset($schema['mainEntityOfPage'])) {
2366 + if (is_array($schema['mainEntityOfPage'])) {
2367 + if (isset($schema['mainEntityOfPage']['@id'])) {
2368 + $schema['mainEntityOfPage']['@id'] = $permalink;
2369 + }
2370 + } else {
2371 + $schema['mainEntityOfPage'] = $permalink;
2372 + }
2373 + }
2374 +
2375 + return $schema;
2376 + }
2377 +
2378 + /**
2379 + * Normalise properties that stored snapshots may hold in a stale format.
2380 + *
2381 + * Deployed schema is written once and read forever, so a formatting fix in
2382 + * the builder never reaches rows already on disk. Correcting on read means
2383 + * existing sites benefit without a migration.
2384 + *
2385 + * Covers non-ISO-8601 dates (#465) and WP locales in inLanguage, which must
2386 + * be a BCP-47 tag — en-US, not en_US (#473). Walks nested nodes so values
2387 + * inside author/publisher/@graph entries are covered too.
2388 + *
2389 + * Also decodes HTML entities in plain-text properties. Schema_Builder
2390 + * stored the block editor's `&amp;` as-is until 2.10.0, and nothing
2391 + * decodes JSON-LD downstream, so every deployed node built from post text
2392 + * published the entity literally.
2393 + *
2394 + * @since 1.16.0
2395 + * @since 2.10.0 Decodes entities in plain-text properties.
2396 + *
2397 + * @param array $schema Decoded schema data.
2398 + * @return array Normalised schema.
2399 + */
2400 + private function normalize_stored_schema(array $schema): array {
2401 + static $date_keys = [
2402 + 'datePublished', 'dateModified', 'dateCreated', 'uploadDate',
2403 + 'startDate', 'endDate', 'validFrom', 'validThrough', 'expires',
2404 + ];
2405 +
2406 + // Plain text in schema.org. Answer/HowToStep `text` is deliberately
2407 + // absent: Google reads Answer.text as HTML, where an entity is correct
2408 + // and decoding `&lt;` would turn escaped text into live markup.
2409 + static $text_keys = [
2410 + 'name', 'headline', 'alternativeHeadline', 'description',
2411 + 'reviewBody', 'about', 'abstract', 'caption',
2412 + ];
2413 +
2414 + foreach ($schema as $key => $value) {
2415 + if (is_array($value)) {
2416 + $schema[$key] = $this->normalize_stored_schema($value);
2417 + continue;
2418 + }
2419 +
2420 + if ('inLanguage' === $key && is_string($value) && '' !== $value) {
2421 + $schema[$key] = str_replace('_', '-', $value);
2422 + continue;
2423 + }
2424 +
2425 + // Decode only: a snapshot already truncated with an ellipsis must
2426 + // keep it, which the full Seo_Text::normalize_schema_text() would
2427 + // strip as an excerpt marker.
2428 + if (in_array($key, $text_keys, true) && is_string($value) && '' !== $value) {
2429 + $schema[$key] = \ThinkRank\Core\Seo_Text::decode_schema_entities($value);
2430 + continue;
2431 + }
2432 +
2433 + if (!in_array($key, $date_keys, true) || !is_string($value) || '' === $value) {
2434 + continue;
2435 + }
2436 +
2437 + // Already ISO 8601 — leave it alone.
2438 + if (preg_match('/^\d{4}-\d{2}-\d{2}T/', $value)) {
2439 + continue;
2440 + }
2441 +
2442 + $timestamp = strtotime($value);
2443 +
2444 + if (false !== $timestamp) {
2445 + $schema[$key] = (string) wp_date('c', $timestamp);
2446 + }
2447 + }
2448 +
2449 + return $schema;
2450 + }
2451 +
2452 + /**
1760 2453 * Clean up duplicate schemas in database
1761 2454 *
1762 2455 * @since 1.0.0
1763 2456 *
@@ -1809,28 +2502,131 @@
1809 2502 }
1810 2503
1811 2504 return $deleted ?: 0;
1812 2505 }
2506 +
1813 2507 /**
1814 - * Delete all existing schemas for a context before storing new ones
2508 + * Deactivate deployed schema rows for the given types.
1815 2509 *
2510 + * Deployment was insert-only, so anything ever deployed to a context stayed
2511 + * on the page forever — switching a post's schema type left the old one live
2512 + * and deactivating a saved schema did nothing (#464). Rows are deactivated
2513 + * rather than deleted so a later redeploy can revive them and so there is a
2514 + * trail of what was published.
2515 + *
2516 + * @since 1.16.0
2517 + *
2518 + * @param string $context_type Context type.
2519 + * @param int|null $context_id Context ID.
2520 + * @param string[] $schema_types Types to retire.
2521 + * @return int Number of rows deactivated.
2522 + */
2523 + private function retire_schema_types(string $context_type, ?int $context_id, array $schema_types): int {
2524 + $schema_types = array_values(array_filter(array_map('strval', $schema_types), 'strlen'));
2525 +
2526 + if (empty($schema_types)) {
2527 + return 0;
2528 + }
2529 +
2530 + global $wpdb;
2531 +
2532 + $table_name = $wpdb->prefix . 'thinkrank_seo_schema';
2533 + $placeholders = implode(', ', array_fill(0, count($schema_types), '%s'));
2534 +
2535 + if (null === $context_id) {
2536 + $sql = sprintf(
2537 + 'UPDATE %s SET is_active = 0 WHERE context_type = %%s AND context_id IS NULL AND schema_type IN (%s)',
2538 + $table_name,
2539 + $placeholders
2540 + );
2541 + $args = array_merge([$context_type], $schema_types);
2542 + } else {
2543 + $sql = sprintf(
2544 + 'UPDATE %s SET is_active = 0 WHERE context_type = %%s AND context_id = %%d AND schema_type IN (%s)',
2545 + $table_name,
2546 + $placeholders
2547 + );
2548 + $args = array_merge([$context_type, $context_id], $schema_types);
2549 + }
2550 +
2551 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Retiring deployed schema rows requires direct database access.
2552 + $updated = $wpdb->query(
2553 + $wpdb->prepare(
2554 + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is built from an internal table name and generated placeholders.
2555 + $sql,
2556 + $args
2557 + )
2558 + );
2559 +
2560 + if ($updated && $this->cache_manager) {
2561 + $this->cache_manager->invalidate_context_cache($context_type, $context_id);
2562 + }
2563 +
2564 + return (int) ($updated ?: 0);
2565 + }
2566 +
2567 + /**
2568 + * Retire deployed types that are no longer in the user's Schema Types selection.
2569 + *
2570 + * An empty selection means "auto-detect", so nothing is retired in that case.
2571 + *
2572 + * @since 1.16.0
2573 + *
2574 + * @param string $context_type Context type.
2575 + * @param int|null $context_id Context ID.
2576 + * @param array $enabled_types The user's selected types.
2577 + * @return int Number of rows deactivated.
2578 + */
2579 + private function retire_unselected_schema_types(string $context_type, ?int $context_id, array $enabled_types): int {
2580 + if (empty($enabled_types)) {
2581 + return 0;
2582 + }
2583 +
2584 + $deployed = array_keys($this->get_deployed_schemas($context_type, $context_id));
2585 + $stale = array_diff($deployed, $enabled_types);
2586 +
2587 + return $this->retire_schema_types($context_type, $context_id, $stale);
2588 + }
2589 +
2590 + /**
2591 + * Delete stored schemas for a context before storing new ones.
2592 + *
2593 + * `$schema_types` scopes the delete to the types actually being rewritten.
2594 + * Without it this wiped every type in the context, which silently destroyed
2595 + * deployed schema whenever a caller regenerated a subset — and
2596 + * auto_deploy_schema_on_settings_change() regenerates one type at a time
2597 + * (#454). Passing an empty array keeps the original clear-the-context
2598 + * behaviour for callers that genuinely rewrite everything.
2599 + *
1816 2600 * @since 1.0.0
1817 2601 *
1818 2602 * @param string $context_type Context type
1819 2603 * @param int|null $context_id Context ID
2604 + * @param string[] $schema_types Optional. Limit the delete to these types.
1820 2605 * @return int Number of schemas deleted
1821 2606 */
1822 - private function delete_existing_schemas(string $context_type, ?int $context_id): int {
2607 + private function delete_existing_schemas(string $context_type, ?int $context_id, array $schema_types = []): int {
1823 2608 global $wpdb;
1824 2609
1825 2610 $table_name = $wpdb->prefix . 'thinkrank_seo_schema';
1826 2611
2612 + // Build an optional `AND schema_type IN (…)` clause with one prepared
2613 + // placeholder per type, so the scoping cannot be injected through.
2614 + $type_clause = '';
2615 + $type_values = [];
2616 + $schema_types = array_values(array_filter(array_map('strval', $schema_types), 'strlen'));
2617 + if (!empty($schema_types)) {
2618 + $type_clause = ' AND schema_type IN (' . implode(', ', array_fill(0, count($schema_types), '%s')) . ')';
2619 + $type_values = $schema_types;
2620 + }
2621 +
1827 2622 if (null === $context_id) {
1828 2623 // Delete all schemas for NULL context_id
1829 2624 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema deletion requires direct database access
1830 2625 $sql = sprintf(
1831 - 'DELETE FROM %s WHERE context_type = %%s AND context_id IS NULL',
1832 - $table_name
2626 + 'DELETE FROM %s WHERE context_type = %%s AND context_id IS NULL%s',
2627 + $table_name,
2628 + $type_clause
1833 2629 );
1834 2630 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema deletion requires direct database access
1835 2631 $deleted = $wpdb->query(
1836 2632 $wpdb->prepare(
@@ -1835,9 +2631,9 @@
1835 2631 $deleted = $wpdb->query(
1836 2632 $wpdb->prepare(
1837 2633 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
1838 2634 $sql,
1839 - $context_type
2635 + array_merge([$context_type], $type_values)
1840 2636 )
1841 2637 );
1842 2638 } else {
1843 2639 // Delete all schemas for specific context_id
@@ -1842,10 +2638,11 @@
1842 2638 } else {
1843 2639 // Delete all schemas for specific context_id
1844 2640 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema deletion requires direct database access
1845 2641 $sql = sprintf(
1846 - 'DELETE FROM %s WHERE context_type = %%s AND context_id = %%d',
1847 - $table_name
2642 + 'DELETE FROM %s WHERE context_type = %%s AND context_id = %%d%s',
2643 + $table_name,
2644 + $type_clause
1848 2645 );
1849 2646 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema deletion requires direct database access
1850 2647 $deleted = $wpdb->query(
1851 2648 $wpdb->prepare(
@@ -1850,10 +2647,9 @@
1850 2647 $deleted = $wpdb->query(
1851 2648 $wpdb->prepare(
1852 2649 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
1853 2650 $sql,
1854 - $context_type,
1855 - $context_id
2651 + array_merge([$context_type, $context_id], $type_values)
1856 2652 )
1857 2653 );
1858 2654 }
1859 2655
@@ -1937,8 +2733,11 @@
1937 2733 'site_url' => home_url(),
1938 2734 'admin_email' => get_option('admin_email'),
1939 2735 'language' => get_locale(),
1940 2736 'timezone' => get_option('timezone_string'),
2737 + // Read by populate_website_schema(), so the deployed WebSite node
2738 + // carries the same alternateName as the default one (#692).
2739 + 'alternate_name' => $site_identity_settings['alternate_name'] ?? '',
1941 2740 'founded_date' => $site_identity_settings['founded_date'] ?? '',
1942 2741 'founder_name' => $site_identity_settings['founder_name'] ?? '',
1943 2742 'company_type' => $site_identity_settings['company_type'] ?? 'Organization',
1944 2743 // Site Identity assets
@@ -1963,9 +2762,9 @@
1963 2762 'person_address' => $schema_settings['person_address'] ?? '',
1964 2763 'person_birth_date' => $schema_settings['person_birth_date'] ?? '',
1965 2764 'person_nationality' => $schema_settings['person_nationality'] ?? '',
1966 2765 'person_works_for' => $schema_settings['person_works_for'] ?? '',
1967 - 'person_same_as' => $schema_settings['person_same_as'] ?? array(),
2766 + 'person_same_as' => $schema_settings['person_same_as'] ?? [],
1968 2767
1969 2768 // Website schema settings (site-wide)
1970 2769 'website_name' => $schema_settings['website_name'] ?? '',
1971 2770 'website_url' => $schema_settings['website_url'] ?? '',