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 +805 -70 1.25.0 → 2.10.0 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
@@ -574,8 +756,24 @@
574 756
575 757 // Determine deployment method
576 758 $deployment['deployment_method'] = $this->determine_deployment_method($options);
577 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 +
578 776 // Deploy each schema
579 777 foreach ($schema_data as $schema_type => $schema) {
580 778 $deploy_result = $this->deploy_single_schema($schema, $schema_type, $deployment['deployment_method'], $context_type, $context_id);
581 779 $deployment['deployed_schemas'][$schema_type] = $deploy_result;
@@ -584,21 +782,91 @@
584 782 // Clean up duplicate schemas
585 783 $this->cleanup_duplicate_schemas($context_type, $context_id);
586 784
587 785 // CACHE INVALIDATION: Clear cache after successful deployment
786 + $cache_invalidated = false;
588 787 if ($this->cache_manager && !empty($deployment['deployed_schemas'])) {
589 788 $this->cache_manager->invalidate_context_cache($context_type, $context_id);
789 + $cache_invalidated = true;
590 790 }
591 791
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';
792 + $deployment['cache_status'] = $cache_invalidated
793 + ? ['cache_updated' => true, 'message' => 'Schema cache invalidated']
794 + : ['cache_updated' => false, 'message' => 'No schema cache to invalidate'];
596 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 +
597 819 return $deployment;
598 820 }
599 821
600 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 + /**
601 869 * Track schema performance and rich snippet appearances
602 870 *
603 871 * @since 1.0.0
604 872 *
@@ -713,9 +981,11 @@
713 981 'validation_results' => [],
714 982 'rich_snippets_preview' => [],
715 983 'performance_data' => [],
716 984 'recommendations' => [],
717 - '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)
718 988 ];
719 989
720 990 // Get enabled schema types
721 991 $enabled_types = $settings['enabled_schema_types'] ?? [];
@@ -823,9 +1093,9 @@
823 1093 // For site context: only apply site-level schema settings
824 1094 if ($context_type === 'site') {
825 1095 // Add local business schema if enabled
826 1096 if ($options['enable_local_business'] ?? false) {
827 - if (!in_array('LocalBusiness', $enabled_types)) {
1097 + if (!in_array('LocalBusiness', $enabled_types, true)) {
828 1098 $enabled_types[] = 'LocalBusiness';
829 1099 }
830 1100 }
831 1101
@@ -835,9 +1105,9 @@
835 1105 // For post/page context: apply all schema settings (metabox functionality)
836 1106
837 1107 // Add article schema if enabled and context is appropriate
838 1108 if ($options['enable_article_schema'] ?? false) {
839 - 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)) {
840 1110 $enabled_types[] = 'Article';
841 1111 }
842 1112 }
843 1113
@@ -842,9 +1112,9 @@
842 1112 }
843 1113
844 1114 // Add FAQ schema if enabled
845 1115 if ($options['enable_faq_schema'] ?? false) {
846 - if (!in_array('FAQPage', $enabled_types)) {
1116 + if (!in_array('FAQPage', $enabled_types, true)) {
847 1117 $enabled_types[] = 'FAQPage';
848 1118 }
849 1119 }
850 1120
@@ -849,9 +1119,9 @@
849 1119 }
850 1120
851 1121 // Add How-To schema if enabled
852 1122 if ($options['enable_howto_schema'] ?? false) {
853 - if (!in_array('HowTo', $enabled_types)) {
1123 + if (!in_array('HowTo', $enabled_types, true)) {
854 1124 $enabled_types[] = 'HowTo';
855 1125 }
856 1126 }
857 1127
@@ -856,9 +1126,9 @@
856 1126 }
857 1127
858 1128 // Add product schema if enabled and context is appropriate
859 1129 if ($options['enable_product_schema'] ?? false) {
860 - if ($context_type === 'product' && !in_array('Product', $enabled_types)) {
1130 + if ($context_type === 'product' && !in_array('Product', $enabled_types, true)) {
861 1131 $enabled_types[] = 'Product';
862 1132 }
863 1133 }
864 1134
@@ -863,9 +1133,9 @@
863 1133 }
864 1134
865 1135 // Add local business schema if enabled
866 1136 if ($options['enable_local_business'] ?? false) {
867 - if (!in_array('LocalBusiness', $enabled_types)) {
1137 + if (!in_array('LocalBusiness', $enabled_types, true)) {
868 1138 $enabled_types[] = 'LocalBusiness';
869 1139 }
870 1140 }
871 1141
@@ -886,9 +1156,9 @@
886 1156 // For site context: only apply site-level schema settings
887 1157 if ($context_type === 'site') {
888 1158 // Add local business schema if enabled
889 1159 if ($settings['enable_local_business'] ?? false) {
890 - if (!in_array('LocalBusiness', $enabled_types)) {
1160 + if (!in_array('LocalBusiness', $enabled_types, true)) {
891 1161 $enabled_types[] = 'LocalBusiness';
892 1162 }
893 1163 }
894 1164
@@ -893,9 +1163,9 @@
893 1163 }
894 1164
895 1165 // Add breadcrumbs schema if enabled (site-wide feature)
896 1166 if ($settings['enable_breadcrumbs_schema'] ?? false) {
897 - if (!in_array('BreadcrumbList', $enabled_types)) {
1167 + if (!in_array('BreadcrumbList', $enabled_types, true)) {
898 1168 $enabled_types[] = 'BreadcrumbList';
899 1169 }
900 1170 }
901 1171
@@ -905,9 +1175,9 @@
905 1175 // For post/page context: apply all schema settings (metabox functionality)
906 1176
907 1177 // Add article schema if enabled and context is appropriate
908 1178 if ($settings['enable_article_schema'] ?? false) {
909 - 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)) {
910 1180 $enabled_types[] = 'Article';
911 1181 }
912 1182 }
913 1183
@@ -912,9 +1182,9 @@
912 1182 }
913 1183
914 1184 // Add FAQ schema if enabled
915 1185 if ($settings['enable_faq_schema'] ?? false) {
916 - if (!in_array('FAQPage', $enabled_types)) {
1186 + if (!in_array('FAQPage', $enabled_types, true)) {
917 1187 $enabled_types[] = 'FAQPage';
918 1188 }
919 1189 }
920 1190
@@ -919,9 +1189,9 @@
919 1189 }
920 1190
921 1191 // Add How-To schema if enabled
922 1192 if ($settings['enable_howto_schema'] ?? false) {
923 - if (!in_array('HowTo', $enabled_types)) {
1193 + if (!in_array('HowTo', $enabled_types, true)) {
924 1194 $enabled_types[] = 'HowTo';
925 1195 }
926 1196 }
927 1197
@@ -926,9 +1196,9 @@
926 1196 }
927 1197
928 1198 // Add product schema if enabled and context is appropriate
929 1199 if ($settings['enable_product_schema'] ?? false) {
930 - if ($context_type === 'product' && !in_array('Product', $enabled_types)) {
1200 + if ($context_type === 'product' && !in_array('Product', $enabled_types, true)) {
931 1201 $enabled_types[] = 'Product';
932 1202 }
933 1203 }
934 1204
@@ -933,9 +1203,9 @@
933 1203 }
934 1204
935 1205 // Add local business schema if enabled
936 1206 if ($settings['enable_local_business'] ?? false) {
937 - if (!in_array('LocalBusiness', $enabled_types)) {
1207 + if (!in_array('LocalBusiness', $enabled_types, true)) {
938 1208 $enabled_types[] = 'LocalBusiness';
939 1209 }
940 1210 }
941 1211
@@ -969,8 +1239,47 @@
969 1239 return home_url('/wp-content/plugins/thinkrank/assets/images/default-logo.jpg');
970 1240 }
971 1241
972 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 + /**
973 1282 * Get default settings for a context type (implements interface)
974 1283 *
975 1284 * @since 1.0.0
976 1285 *
@@ -1062,38 +1371,59 @@
1062 1371 if ($this->has_person_settings_changed($settings)) {
1063 1372 $schema_types_to_regenerate[] = 'Person';
1064 1373 }
1065 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 +
1066 1391 // If no schema types need regeneration, return early
1067 1392 if (empty($schema_types_to_regenerate)) {
1068 1393 return;
1069 1394 }
1070 1395
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 - );
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 + );
1080 1406
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 - );
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];
1088 1414 }
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 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 + }
1096 1426 }
1097 1427 }
1098 1428
1099 1429 /**
@@ -1131,9 +1461,15 @@
1131 1461 * @param array $settings Updated settings
1132 1462 * @return bool True if website settings changed
1133 1463 */
1134 1464 private function has_website_settings_changed(array $settings): bool {
1135 - $website_keys = ['site_name', 'site_description', 'site_url'];
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 + ];
1136 1472
1137 1473 foreach ($website_keys as $key) {
1138 1474 if (isset($settings[$key])) {
1139 1475 return true;
@@ -1151,11 +1487,20 @@
1151 1487 * @param array $settings Updated settings
1152 1488 * @return bool True if business settings changed
1153 1489 */
1154 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().
1155 1497 $business_keys = [
1156 - 'business_name', 'business_type', 'business_address', 'business_phone',
1157 - 'business_hours', 'business_price_range'
1498 + 'enable_local_business',
1499 + 'business_price_range',
1500 + 'business_geo_latitude',
1501 + 'business_geo_longitude',
1502 + 'business_opening_hours',
1158 1503 ];
1159 1504
1160 1505 foreach ($business_keys as $key) {
1161 1506 if (isset($settings[$key])) {
@@ -1199,8 +1544,29 @@
1199 1544 $detected_types = [];
1200 1545
1201 1546 switch ($context_type) {
1202 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 +
1203 1569 $detected_types = ['Organization'];
1204 1570 // Check if it's a local business
1205 1571 if ($this->is_local_business()) {
1206 1572 $detected_types[] = 'LocalBusiness';
@@ -1259,9 +1625,9 @@
1259 1625 'business_data' => $this->get_business_data_from_local_seo(),
1260 1626 'site_data' => $this->get_site_data_for_schema(),
1261 1627 'social_data' => $this->get_social_data_for_schema()
1262 1628 ];
1263 - } elseif ($context_id && in_array($context_type, ['post', 'page', 'product'])) {
1629 + } elseif ($context_id && in_array($context_type, ['post', 'page', 'product'], true)) {
1264 1630 // Post/page/product data
1265 1631 $post = get_post($context_id);
1266 1632 if ($post) {
1267 1633 $content_data = [
@@ -1266,16 +1632,20 @@
1266 1632 if ($post) {
1267 1633 $content_data = [
1268 1634 'title' => $post->post_title,
1269 1635 'url' => get_permalink($post->ID),
1270 - '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),
1271 1637 'content' => $post->post_content,
1272 1638 'author' => [
1273 1639 'name' => get_the_author_meta('display_name', $post->post_author),
1274 1640 'url' => get_author_posts_url($post->post_author)
1275 1641 ],
1276 - 'date' => $post->post_date,
1277 - '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),
1278 1648 'image' => get_the_post_thumbnail_url($post->ID, 'full'),
1279 1649 'focus_keywords' => Focus_Keywords::get($post->ID),
1280 1650 'business_data' => $this->get_business_data_from_local_seo(),
1281 1651 'site_data' => $this->get_site_data_for_schema(),
@@ -1370,10 +1740,16 @@
1370 1740 global $wpdb;
1371 1741
1372 1742 $table_name = $wpdb->prefix . 'thinkrank_seo_schema';
1373 1743
1374 - // First, delete all existing schemas for this context to ensure clean storage
1375 - $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);
1376 1752
1377 1753 foreach ($generation['generated_schemas'] as $schema_type => $schema_data) {
1378 1754 // Prepare schema data with validation status embedded
1379 1755 $schema_data_with_validation = $schema_data;
@@ -1389,9 +1765,13 @@
1389 1765 'context_id' => $context_id,
1390 1766 'schema_type' => $schema_type,
1391 1767 'schema_data' => wp_json_encode($schema_data_with_validation),
1392 1768 'validation_status' => $generation['validation_results'][$schema_type]['is_valid'] ? 'valid' : 'invalid',
1393 - '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
1394 1774 ];
1395 1775
1396 1776 // Insert new schema (existing ones were already deleted)
1397 1777 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema insertion requires direct database access
@@ -1722,11 +2102,14 @@
1722 2102 ARRAY_A
1723 2103 );
1724 2104 }
1725 2105
1726 - if (empty($deployed_schemas)) {
1727 - return [];
1728 - }
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 ?: [];
1729 2112
1730 2113 // Process schemas for return
1731 2114 $processed_schemas = [];
1732 2115 foreach ($deployed_schemas as $deployed_schema) {
@@ -1738,8 +2121,26 @@
1738 2121 if (isset($schema_data['_validation'])) {
1739 2122 unset($schema_data['_validation']);
1740 2123 }
1741 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 +
1742 2143 $processed_schemas[$schema_type] = [
1743 2144 'data' => $schema_data,
1744 2145 'method' => 'json_ld', // Default method
1745 2146 'type' => $schema_type
@@ -1746,10 +2147,13 @@
1746 2147 ];
1747 2148 }
1748 2149 }
1749 2150
1750 - // CACHE LAYER: Store result in cache for future requests
1751 - 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) {
1752 2156 $cache_key = $this->cache_manager->generate_deployed_schemas_key($context_type, $context_id);
1753 2157 $this->cache_manager->set($cache_key, $processed_schemas);
1754 2158 }
1755 2159
@@ -1756,8 +2160,233 @@
1756 2160 return $processed_schemas;
1757 2161 }
1758 2162
1759 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 + /**
1760 2389 * Clean up duplicate schemas in database
1761 2390 *
1762 2391 * @since 1.0.0
1763 2392 *
@@ -1809,28 +2438,131 @@
1809 2438 }
1810 2439
1811 2440 return $deleted ?: 0;
1812 2441 }
2442 +
1813 2443 /**
1814 - * Delete all existing schemas for a context before storing new ones
2444 + * Deactivate deployed schema rows for the given types.
1815 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 + *
1816 2536 * @since 1.0.0
1817 2537 *
1818 2538 * @param string $context_type Context type
1819 2539 * @param int|null $context_id Context ID
2540 + * @param string[] $schema_types Optional. Limit the delete to these types.
1820 2541 * @return int Number of schemas deleted
1821 2542 */
1822 - 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 {
1823 2544 global $wpdb;
1824 2545
1825 2546 $table_name = $wpdb->prefix . 'thinkrank_seo_schema';
1826 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 +
1827 2558 if (null === $context_id) {
1828 2559 // Delete all schemas for NULL context_id
1829 2560 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema deletion requires direct database access
1830 2561 $sql = sprintf(
1831 - 'DELETE FROM %s WHERE context_type = %%s AND context_id IS NULL',
1832 - $table_name
2562 + 'DELETE FROM %s WHERE context_type = %%s AND context_id IS NULL%s',
2563 + $table_name,
2564 + $type_clause
1833 2565 );
1834 2566 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema deletion requires direct database access
1835 2567 $deleted = $wpdb->query(
1836 2568 $wpdb->prepare(
@@ -1835,9 +2567,9 @@
1835 2567 $deleted = $wpdb->query(
1836 2568 $wpdb->prepare(
1837 2569 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
1838 2570 $sql,
1839 - $context_type
2571 + array_merge([$context_type], $type_values)
1840 2572 )
1841 2573 );
1842 2574 } else {
1843 2575 // Delete all schemas for specific context_id
@@ -1842,10 +2574,11 @@
1842 2574 } else {
1843 2575 // Delete all schemas for specific context_id
1844 2576 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema deletion requires direct database access
1845 2577 $sql = sprintf(
1846 - 'DELETE FROM %s WHERE context_type = %%s AND context_id = %%d',
1847 - $table_name
2578 + 'DELETE FROM %s WHERE context_type = %%s AND context_id = %%d%s',
2579 + $table_name,
2580 + $type_clause
1848 2581 );
1849 2582 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema deletion requires direct database access
1850 2583 $deleted = $wpdb->query(
1851 2584 $wpdb->prepare(
@@ -1850,10 +2583,9 @@
1850 2583 $deleted = $wpdb->query(
1851 2584 $wpdb->prepare(
1852 2585 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
1853 2586 $sql,
1854 - $context_type,
1855 - $context_id
2587 + array_merge([$context_type, $context_id], $type_values)
1856 2588 )
1857 2589 );
1858 2590 }
1859 2591
@@ -1937,8 +2669,11 @@
1937 2669 'site_url' => home_url(),
1938 2670 'admin_email' => get_option('admin_email'),
1939 2671 'language' => get_locale(),
1940 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'] ?? '',
1941 2676 'founded_date' => $site_identity_settings['founded_date'] ?? '',
1942 2677 'founder_name' => $site_identity_settings['founder_name'] ?? '',
1943 2678 'company_type' => $site_identity_settings['company_type'] ?? 'Organization',
1944 2679 // Site Identity assets
@@ -1963,9 +2698,9 @@
1963 2698 'person_address' => $schema_settings['person_address'] ?? '',
1964 2699 'person_birth_date' => $schema_settings['person_birth_date'] ?? '',
1965 2700 'person_nationality' => $schema_settings['person_nationality'] ?? '',
1966 2701 'person_works_for' => $schema_settings['person_works_for'] ?? '',
1967 - 'person_same_as' => $schema_settings['person_same_as'] ?? array(),
2702 + 'person_same_as' => $schema_settings['person_same_as'] ?? [],
1968 2703
1969 2704 // Website schema settings (site-wide)
1970 2705 'website_name' => $schema_settings['website_name'] ?? '',
1971 2706 'website_url' => $schema_settings['website_url'] ?? '',