PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.13.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.13.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 1.26.0 1.25.0 All 54 releases
← All changes | includes/seo/class-schema-management-system.php +798 -89 1.32.0 → 2.13.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;
@@ -661,9 +859,9 @@
661 859 }
662 860
663 861 return [
664 862 'validation_passed' => true,
665 - 'message' => __('Schema deployed and verified on the front end', 'thinkrank'),
863 + 'message' => __('Schema deployed and read back from storage', 'thinkrank'),
666 864 'missing_types' => []
667 865 ];
668 866 }
669 867
@@ -783,9 +981,11 @@
783 981 'validation_results' => [],
784 982 'rich_snippets_preview' => [],
785 983 'performance_data' => [],
786 984 'recommendations' => [],
787 - '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)
788 988 ];
789 989
790 990 // Get enabled schema types
791 991 $enabled_types = $settings['enabled_schema_types'] ?? [];
@@ -1039,8 +1239,47 @@
1039 1239 return home_url('/wp-content/plugins/thinkrank/assets/images/default-logo.jpg');
1040 1240 }
1041 1241
1042 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 + /**
1043 1282 * Get default settings for a context type (implements interface)
1044 1283 *
1045 1284 * @since 1.0.0
1046 1285 *
@@ -1086,9 +1325,9 @@
1086 1325 $this->cache_manager->invalidate_all_cache();
1087 1326 }
1088 1327
1089 1328 // AUTO-DEPLOY: Automatically regenerate and deploy schema when settings change
1090 - if ($success && !empty($settings['auto_deploy'])) {
1329 + if ($success && self::should_auto_deploy($settings, $this->get_settings($context_type, $context_id))) {
1091 1330 $this->auto_deploy_schema_on_settings_change($context_type, $context_id, $settings);
1092 1331 }
1093 1332
1094 1333 return $success;
@@ -1093,9 +1332,35 @@
1093 1332
1094 1333 return $success;
1095 1334 }
1096 1335
1336 +
1097 1337 /**
1338 + * Whether a save should redeploy the schema it changed.
1339 + *
1340 + * `auto_deploy` is a stored setting (on by default), not something a save
1341 + * restates. Reading it off the incoming patch meant a partial save — which
1342 + * is what the admin screen sends, one field at a time — skipped
1343 + * auto-deploy on a site that had it switched on, and the deployed snapshot
1344 + * the front end serves kept the name, logo and sameAs it was deployed
1345 + * with, however often the user saved (#904, the gate #12 left in place).
1346 + *
1347 + * A patch that does carry the key still wins, so a caller can deploy or
1348 + * hold deliberately.
1349 + *
1350 + * @param array $patch Settings being saved
1351 + * @param array $stored Settings as stored, after the save
1352 + * @return bool
1353 + */
1354 + public static function should_auto_deploy(array $patch, array $stored): bool {
1355 + if (array_key_exists('auto_deploy', $patch)) {
1356 + return !empty($patch['auto_deploy']);
1357 + }
1358 +
1359 + return !empty($stored['auto_deploy']);
1360 + }
1361 +
1362 + /**
1098 1363 * Auto-deploy schema when settings change
1099 1364 *
1100 1365 * Automatically regenerates and deploys schema markup when organization or other
1101 1366 * schema settings are modified, ensuring the frontend output stays in sync.
@@ -1132,38 +1397,59 @@
1132 1397 if ($this->has_person_settings_changed($settings)) {
1133 1398 $schema_types_to_regenerate[] = 'Person';
1134 1399 }
1135 1400
1401 + // Honour the user's Schema Types selection. Without this the payload
1402 + // shape alone decided what shipped, so every save deployed all four
1403 + // types — including ones the user had explicitly deselected (#461).
1404 + // An empty selection means "auto", so only filter when one is set.
1405 + $enabled_types = $settings['enabled_schema_types'] ?? $this->get_settings($context_type, $context_id)['enabled_schema_types'] ?? [];
1406 +
1407 + if (!empty($enabled_types) && is_array($enabled_types)) {
1408 + $schema_types_to_regenerate = array_values(
1409 + array_intersect($schema_types_to_regenerate, $enabled_types)
1410 + );
1411 + }
1412 +
1413 + // Types that were deployed but are no longer wanted must come back off
1414 + // the page — deployment used to be additive-only (#464).
1415 + $this->retire_unselected_schema_types($context_type, $context_id, $enabled_types);
1416 +
1136 1417 // If no schema types need regeneration, return early
1137 1418 if (empty($schema_types_to_regenerate)) {
1138 1419 return;
1139 1420 }
1140 1421
1141 - // Generate and deploy each schema type
1142 - foreach ($schema_types_to_regenerate as $schema_type) {
1143 - try {
1144 - // Generate schema using generate_schema_markup
1145 - $generation_result = $this->generate_schema_markup(
1146 - $context_type,
1147 - $context_id,
1148 - [$schema_type]
1149 - );
1422 + // Generate every affected type in ONE call. Generating them one at a
1423 + // time re-entered store_schema_data() per type, and each pass replaced
1424 + // the rows written by the previous one, so only the last type survived
1425 + // (#454). One batch also means one delete and one cache flush.
1426 + try {
1427 + $generation_result = $this->generate_schema_markup(
1428 + $context_type,
1429 + $context_id,
1430 + $schema_types_to_regenerate
1431 + );
1150 1432
1151 - // Deploy if generation was successful
1152 - if (!empty($generation_result['generated_schemas'][$schema_type]) && $generation_result['deployment_ready']) {
1153 - $this->deploy_schema_markup(
1154 - $context_type,
1155 - $context_id,
1156 - [$schema_type => $generation_result['generated_schemas'][$schema_type]]
1157 - );
1433 + $deployable = [];
1434 + foreach ($schema_types_to_regenerate as $schema_type) {
1435 + // Only deploy what validated — see #470.
1436 + if (!empty($generation_result['generated_schemas'][$schema_type])
1437 + && !empty($generation_result['validation_results'][$schema_type]['is_valid'])
1438 + ) {
1439 + $deployable[$schema_type] = $generation_result['generated_schemas'][$schema_type];
1158 1440 }
1159 - } catch (\Exception $e) {
1160 - // Log error but don't fail the settings save
1161 - if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
1162 - // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
1163 - error_log('ThinkRank: Auto-deploy failed for ' . $schema_type . ': ' . $e->getMessage());
1164 - }
1165 1441 }
1442 +
1443 + if (!empty($deployable)) {
1444 + $this->deploy_schema_markup($context_type, $context_id, $deployable);
1445 + }
1446 + } catch (\Exception $e) {
1447 + // Log error but don't fail the settings save
1448 + if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
1449 + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
1450 + error_log('ThinkRank: Auto-deploy failed for ' . implode(', ', $schema_types_to_regenerate) . ': ' . $e->getMessage());
1451 + }
1166 1452 }
1167 1453 }
1168 1454
1169 1455 /**
@@ -1201,9 +1487,15 @@
1201 1487 * @param array $settings Updated settings
1202 1488 * @return bool True if website settings changed
1203 1489 */
1204 1490 private function has_website_settings_changed(array $settings): bool {
1205 - $website_keys = ['site_name', 'site_description', 'site_url'];
1491 + // These are the keys the Website tab actually stores. It previously
1492 + // looked for site_name/site_description/site_url, which belong to Site
1493 + // Identity and never appear in a schema settings payload — so WebSite
1494 + // schema never auto-deployed no matter what was edited (#455).
1495 + $website_keys = [
1496 + 'website_name', 'website_url', 'website_description', 'website_author',
1497 + ];
1206 1498
1207 1499 foreach ($website_keys as $key) {
1208 1500 if (isset($settings[$key])) {
1209 1501 return true;
@@ -1221,11 +1513,20 @@
1221 1513 * @param array $settings Updated settings
1222 1514 * @return bool True if business settings changed
1223 1515 */
1224 1516 private function has_business_settings_changed(array $settings): bool {
1517 + // Only the keys this manager actually stores. business_name/address/
1518 + // phone/hours live in the site_identity category and never reach a
1519 + // schema settings save, so keying off them meant LocalBusiness never
1520 + // auto-deployed (#455). Edits to those fields refresh LocalBusiness
1521 + // through the Site Identity save path instead — see
1522 + // refresh_schema_for_foreign_settings().
1225 1523 $business_keys = [
1226 - 'business_name', 'business_type', 'business_address', 'business_phone',
1227 - 'business_hours', 'business_price_range'
1524 + 'enable_local_business',
1525 + 'business_price_range',
1526 + 'business_geo_latitude',
1527 + 'business_geo_longitude',
1528 + 'business_opening_hours',
1228 1529 ];
1229 1530
1230 1531 foreach ($business_keys as $key) {
1231 1532 if (isset($settings[$key])) {
@@ -1269,8 +1570,29 @@
1269 1570 $detected_types = [];
1270 1571
1271 1572 switch ($context_type) {
1272 1573 case 'site':
1574 + // The admin's Schema Types selection is the answer to "what
1575 + // does this site need"; detection is only the fallback for an
1576 + // install that has not chosen yet (#456).
1577 + $settings = $this->get_settings($context_type, $context_id);
1578 + $enabled = array_values(array_filter(
1579 + array_map('strval', (array) ($settings['enabled_schema_types'] ?? [])),
1580 + 'strlen'
1581 + ));
1582 +
1583 + // Drop stale names the factory no longer registers rather than
1584 + // handing them to the builder to silently skip.
1585 + $enabled = array_values(array_filter(
1586 + $enabled,
1587 + fn($type) => isset($this->schema_types[$type])
1588 + ));
1589 +
1590 + if (!empty($enabled)) {
1591 + $detected_types = $enabled;
1592 + break;
1593 + }
1594 +
1273 1595 $detected_types = ['Organization'];
1274 1596 // Check if it's a local business
1275 1597 if ($this->is_local_business()) {
1276 1598 $detected_types[] = 'LocalBusiness';
@@ -1333,20 +1655,35 @@
1333 1655 } elseif ($context_id && in_array($context_type, ['post', 'page', 'product'], true)) {
1334 1656 // Post/page/product data
1335 1657 $post = get_post($context_id);
1336 1658 if ($post) {
1659 + // Resolve the featured image's URL to its ID here, where the ID
1660 + // is in hand, so the schema builder does not query for an
1661 + // attachment it was just given (#847). Offered as a hint rather
1662 + // than asserted: `post_thumbnail_url` can swap the URL for one
1663 + // the featured image does not own.
1664 + $thumbnail_url = get_the_post_thumbnail_url($post->ID, 'full');
1665 +
1666 + if ($thumbnail_url) {
1667 + Attachment_Lookup::id_from_url((string) $thumbnail_url, (int) get_post_thumbnail_id($post->ID));
1668 + }
1669 +
1337 1670 $content_data = [
1338 1671 'title' => $post->post_title,
1339 1672 'url' => get_permalink($post->ID),
1340 - 'excerpt' => $post->post_excerpt ?: wp_trim_words($post->post_content, 30),
1673 + 'excerpt' => $post->post_excerpt ?: \ThinkRank\Core\Seo_Text::trim_words($post->post_content, 30),
1341 1674 'content' => $post->post_content,
1342 1675 'author' => [
1343 1676 'name' => get_the_author_meta('display_name', $post->post_author),
1344 1677 'url' => get_author_posts_url($post->post_author)
1345 1678 ],
1346 - 'date' => $post->post_date,
1347 - 'modified' => $post->post_modified,
1348 - 'image' => get_the_post_thumbnail_url($post->ID, 'full'),
1679 + // ISO 8601 with offset. post_date/post_modified are raw
1680 + // MySQL columns in site-local time with no timezone, which
1681 + // Google rejects as "Invalid value in field datePublished"
1682 + // and drops the Article rich result (#465).
1683 + 'date' => get_the_date('c', $post),
1684 + 'modified' => get_the_modified_date('c', $post),
1685 + 'image' => $thumbnail_url,
1349 1686 'focus_keywords' => Focus_Keywords::get($post->ID),
1350 1687 'business_data' => $this->get_business_data_from_local_seo(),
1351 1688 'site_data' => $this->get_site_data_for_schema(),
1352 1689 'social_data' => $this->get_social_data_for_schema()
@@ -1440,10 +1777,16 @@
1440 1777 global $wpdb;
1441 1778
1442 1779 $table_name = $wpdb->prefix . 'thinkrank_seo_schema';
1443 1780
1444 - // First, delete all existing schemas for this context to ensure clean storage
1445 - $this->delete_existing_schemas($context_type, $context_id);
1781 + // Replace only the types in this batch. Clearing the whole context
1782 + // destroyed types the caller never asked about — and callers do
1783 + // regenerate a subset, one type at a time (#454).
1784 + $generated_types = array_keys($generation['generated_schemas'] ?? []);
1785 + if (empty($generated_types)) {
1786 + return false;
1787 + }
1788 + $this->delete_existing_schemas($context_type, $context_id, $generated_types);
1446 1789
1447 1790 foreach ($generation['generated_schemas'] as $schema_type => $schema_data) {
1448 1791 // Prepare schema data with validation status embedded
1449 1792 $schema_data_with_validation = $schema_data;
@@ -1459,9 +1802,13 @@
1459 1802 'context_id' => $context_id,
1460 1803 'schema_type' => $schema_type,
1461 1804 'schema_data' => wp_json_encode($schema_data_with_validation),
1462 1805 'validation_status' => $generation['validation_results'][$schema_type]['is_valid'] ? 'valid' : 'invalid',
1463 - 'is_active' => $generation['deployment_ready'] ? 1 : 0
1806 + // Per-type, not batch-wide. deployment_ready is only true when
1807 + // EVERY type in the batch validated, so one invalid type (a site
1808 + // with no Business Info makes LocalBusiness invalid) deactivated
1809 + // all the valid ones alongside it (#470).
1810 + 'is_active' => !empty($generation['validation_results'][$schema_type]['is_valid']) ? 1 : 0
1464 1811 ];
1465 1812
1466 1813 // Insert new schema (existing ones were already deleted)
1467 1814 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema insertion requires direct database access
@@ -1729,12 +2076,10 @@
1729 2076
1730 2077 /**
1731 2078 * Get deployed schemas for frontend integration
1732 2079 *
1733 - * PERFORMANCE OPTIMIZED: This method now uses:
1734 - * 1. Schema caching layer (90% reduction in database queries)
1735 - * 2. Window function approach instead of correlated subquery (80-90% query performance improvement)
1736 - * 3. Composite index: idx_context_schema_active
2080 + * Returns the newest active, deployed row of each schema type for the
2081 + * context. Results are cached per context (see Schema_Cache_Manager).
1737 2082 *
1738 2083 * @since 1.0.0
1739 2084 *
1740 2085 * @param string $context_type Context type
@@ -1757,47 +2102,59 @@
1757 2102
1758 2103 // Use existing seo_schema table
1759 2104 $table_name = $wpdb->prefix . 'thinkrank_seo_schema';
1760 2105
1761 - // OPTIMIZED QUERY: Use window function approach to eliminate correlated subquery
1762 - // This leverages the new composite index: idx_context_schema_active (context_type, schema_type, is_active, created_at DESC)
2106 + // The newest row per type used to be picked with ROW_NUMBER() OVER
2107 + // (PARTITION BY schema_type ...). Window functions need MySQL 8.0 /
2108 + // MariaDB 10.2, and WordPress still runs on MySQL 5.7, where that is
2109 + // a syntax error on every page view and no deployed schema is ever
2110 + // output. A row is the newest of its type when no other row of the
2111 + // same context and type outranks it, so NOT EXISTS keeps the
2112 + // greatest-per-group in the database, and schema_data — JSON, and
2113 + // large — is only transferred for the rows that are output.
2114 + //
2115 + // `<=>` is NULL-safe equality: the site context stores context_id
2116 + // as NULL, and `n.context_id = s.context_id` is never true for it.
2117 + // schema_id breaks a same-second tie, which the window function
2118 + // left to chance.
2119 + $args = [$context_type];
1763 2120 if (null === $context_id) {
1764 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema retrieval requires direct database access
1765 - $sql = sprintf(
1766 - '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',
1767 - $table_name
1768 - );
1769 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema retrieval requires direct database access
1770 - $deployed_schemas = $wpdb->get_results(
1771 - $wpdb->prepare(
1772 - // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
1773 - $sql,
1774 - $context_type
1775 - ),
1776 - ARRAY_A
1777 - );
2121 + $context_where = 's.context_id IS NULL';
1778 2122 } else {
1779 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema retrieval requires direct database access
1780 - $sql = sprintf(
1781 - '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',
1782 - $table_name
1783 - );
1784 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema retrieval requires direct database access
1785 - $deployed_schemas = $wpdb->get_results(
1786 - $wpdb->prepare(
1787 - // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
1788 - $sql,
1789 - $context_type,
1790 - $context_id
1791 - ),
1792 - ARRAY_A
1793 - );
2123 + $context_where = 's.context_id = %d';
2124 + $args[] = $context_id;
1794 2125 }
1795 2126
1796 - if (empty($deployed_schemas)) {
1797 - return [];
1798 - }
2127 + $sql = sprintf(
2128 + 'SELECT s.schema_type, s.schema_data FROM %1$s s'
2129 + . ' WHERE s.context_type = %%s AND %2$s AND s.is_active = 1 AND s.validation_status IN (\'deployed\', \'valid\')'
2130 + . ' AND NOT EXISTS ('
2131 + . 'SELECT 1 FROM %1$s n'
2132 + . ' WHERE n.context_type = s.context_type AND n.context_id <=> s.context_id AND n.schema_type = s.schema_type'
2133 + . ' AND n.is_active = 1 AND n.validation_status IN (\'deployed\', \'valid\')'
2134 + . ' AND (n.created_at > s.created_at OR (n.created_at = s.created_at AND n.schema_id > s.schema_id))'
2135 + . ')'
2136 + . ' ORDER BY s.schema_type',
2137 + $table_name,
2138 + $context_where
2139 + );
1799 2140
2141 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema retrieval requires direct database access
2142 + $deployed_schemas = $wpdb->get_results(
2143 + $wpdb->prepare(
2144 + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
2145 + $sql,
2146 + ...$args
2147 + ),
2148 + ARRAY_A
2149 + );
2150 +
2151 + // Deliberately no early return on an empty result: it has to reach the
2152 + // cache write below. Most URLs have no deployed schema, so gating the
2153 + // write on a non-empty result made the majority of front-end requests
2154 + // permanent cache misses, re-running the query on every pageview (#392).
2155 + $deployed_schemas = $deployed_schemas ?: [];
2156 +
1800 2157 // Process schemas for return
1801 2158 $processed_schemas = [];
1802 2159 foreach ($deployed_schemas as $deployed_schema) {
1803 2160 $schema_data = json_decode($deployed_schema['schema_data'], true);
@@ -1808,8 +2165,26 @@
1808 2165 if (isset($schema_data['_validation'])) {
1809 2166 unset($schema_data['_validation']);
1810 2167 }
1811 2168
2169 + // Deployed schema is a snapshot, so rows written before #465
2170 + // still carry raw MySQL datetimes. Normalise on read so the
2171 + // fix reaches existing sites without a migration.
2172 + $schema_data = $this->normalize_stored_schema($schema_data);
2173 +
2174 + // The permalink was frozen at deploy time, so schema deployed
2175 + // while a post was a draft advertised "?p=123" as both url and
2176 + // mainEntityOfPage forever — contradicting the node's own @id
2177 + // and the canonical (#470). Resolve it live instead.
2178 + $schema_data = $this->refresh_schema_permalink($schema_data, $context_type, $context_id);
2179 +
2180 + // schema.org types `sameAs`, `url`, `logo` and `image` as URLs,
2181 + // but the form stored whatever was typed, so free text entered
2182 + // in a social-profile field shipped as a sameAs member and made
2183 + // the whole entity invalid (#480). Drop bad values on read, so
2184 + // existing sites stop emitting them without a migration.
2185 + $schema_data = $this->filter_entity_urls($schema_data);
2186 +
1812 2187 $processed_schemas[$schema_type] = [
1813 2188 'data' => $schema_data,
1814 2189 'method' => 'json_ld', // Default method
1815 2190 'type' => $schema_type
@@ -1816,10 +2191,13 @@
1816 2191 ];
1817 2192 }
1818 2193 }
1819 2194
1820 - // CACHE LAYER: Store result in cache for future requests
1821 - if ($this->cache_manager && !empty($processed_schemas)) {
2195 + // CACHE LAYER: Store result in cache for future requests — including
2196 + // an empty one. Cache_Manager::set() wraps the payload in a metadata
2197 + // envelope, so an empty result is still stored as a truthy value and
2198 + // reads back as a hit rather than a miss (#392).
2199 + if ($this->cache_manager) {
1822 2200 $cache_key = $this->cache_manager->generate_deployed_schemas_key($context_type, $context_id);
1823 2201 $this->cache_manager->set($cache_key, $processed_schemas);
1824 2202 }
1825 2203
@@ -1826,8 +2204,233 @@
1826 2204 return $processed_schemas;
1827 2205 }
1828 2206
1829 2207 /**
2208 + * Properties schema.org defines as URLs.
2209 + *
2210 + * @since 2.0.2
2211 + * @var string[]
2212 + */
2213 + private const URL_PROPERTIES = ['sameAs', 'url', 'logo', 'image'];
2214 +
2215 + /**
2216 + * Whether a value is a URL safe to publish in structured data.
2217 + *
2218 + * @since 2.0.2
2219 + *
2220 + * @param mixed $url Candidate value.
2221 + * @return bool
2222 + */
2223 + private function is_publishable_url($url): bool {
2224 + if (!is_string($url) || '' === trim($url)) {
2225 + return false;
2226 + }
2227 +
2228 + if (!filter_var($url, FILTER_VALIDATE_URL)) {
2229 + return false;
2230 + }
2231 +
2232 + $scheme = wp_parse_url($url, PHP_URL_SCHEME);
2233 +
2234 + return in_array(strtolower((string) $scheme), ['http', 'https'], true);
2235 + }
2236 +
2237 + /**
2238 + * Drop values that are not URLs from URL-typed properties.
2239 + *
2240 + * An absent property is valid; one holding free text is not, and it can
2241 + * invalidate the entity around it. Nested objects (`logo` and `image` are
2242 + * frequently ImageObjects) are walked so a bad `url` inside one is caught
2243 + * too. A property left with nothing is removed rather than emitted empty.
2244 + *
2245 + * @since 2.0.2
2246 + *
2247 + * @param array $schema Decoded schema data.
2248 + * @return array Schema carrying only publishable URLs.
2249 + */
2250 + private function filter_entity_urls(array $schema): array {
2251 + foreach ($schema as $key => $value) {
2252 + if (is_array($value) && !in_array($key, self::URL_PROPERTIES, true)) {
2253 + $schema[$key] = $this->filter_entity_urls($value);
2254 + continue;
2255 + }
2256 +
2257 + if (!in_array($key, self::URL_PROPERTIES, true)) {
2258 + continue;
2259 + }
2260 +
2261 + // A nested object (ImageObject and friends) carries its own url.
2262 + if (is_array($value) && isset($value['@type'])) {
2263 + $schema[$key] = $this->filter_entity_urls($value);
2264 + continue;
2265 + }
2266 +
2267 + if (is_array($value)) {
2268 + $kept = [];
2269 +
2270 + foreach ($value as $item) {
2271 + if (is_array($item)) {
2272 + $kept[] = $this->filter_entity_urls($item);
2273 + } elseif ($this->is_publishable_url($item)) {
2274 + $kept[] = $item;
2275 + }
2276 + }
2277 +
2278 + if ([] === $kept) {
2279 + unset($schema[$key]);
2280 + } else {
2281 + $schema[$key] = array_values($kept);
2282 + }
2283 +
2284 + continue;
2285 + }
2286 +
2287 + if (!$this->is_publishable_url($value)) {
2288 + unset($schema[$key]);
2289 + }
2290 + }
2291 +
2292 + return $schema;
2293 + }
2294 +
2295 + /**
2296 + * Schema types whose `url` identifies the entity, not the page.
2297 + *
2298 + * On a Person or an Organization, `url` is that entity's own website, so
2299 + * overwriting it with the permalink of whichever post the schema happens to
2300 + * be deployed on is simply wrong. It also breaks graph assembly: the site
2301 + * identity emits the same entity with its real `url`, and once the two
2302 + * copies disagree they can no longer be recognised as one entity (#479).
2303 + *
2304 + * @since 2.0.2
2305 + * @var string[]
2306 + */
2307 + private const ENTITY_URL_TYPES = ['Person', 'Organization', 'LocalBusiness'];
2308 +
2309 + /**
2310 + * Replace a stored permalink snapshot with the post's live permalink.
2311 + *
2312 + * Only touches `url` and `mainEntityOfPage`, and only for post-like
2313 + * contexts where a permalink actually exists. Identity entities are
2314 + * exempt from the `url` rewrite — see self::ENTITY_URL_TYPES.
2315 + *
2316 + * @since 1.16.0
2317 + *
2318 + * @param array $schema Decoded schema data.
2319 + * @param string $context_type Context type.
2320 + * @param int|null $context_id Context ID.
2321 + * @return array Schema with a current permalink.
2322 + */
2323 + private function refresh_schema_permalink(array $schema, string $context_type, ?int $context_id): array {
2324 + if ('site' === $context_type || empty($context_id)) {
2325 + return $schema;
2326 + }
2327 +
2328 + $permalink = get_permalink($context_id);
2329 +
2330 + if (!$permalink) {
2331 + return $schema;
2332 + }
2333 +
2334 + $type = $schema['@type'] ?? '';
2335 + $type = is_array($type) ? reset($type) : $type;
2336 + // A LocalBusiness is deployed under the subtype the site chose, so the
2337 + // exemption has to cover every subtype, not only the literal root.
2338 + $is_entity = in_array((string) $type, self::ENTITY_URL_TYPES, true)
2339 + || \ThinkRank\Config\Local_Business_Types_Config::is_local_business($type);
2340 +
2341 + if (isset($schema['url']) && !$is_entity) {
2342 + $schema['url'] = $permalink;
2343 + }
2344 +
2345 + if (isset($schema['mainEntityOfPage'])) {
2346 + if (is_array($schema['mainEntityOfPage'])) {
2347 + if (isset($schema['mainEntityOfPage']['@id'])) {
2348 + $schema['mainEntityOfPage']['@id'] = $permalink;
2349 + }
2350 + } else {
2351 + $schema['mainEntityOfPage'] = $permalink;
2352 + }
2353 + }
2354 +
2355 + return $schema;
2356 + }
2357 +
2358 + /**
2359 + * Normalise properties that stored snapshots may hold in a stale format.
2360 + *
2361 + * Deployed schema is written once and read forever, so a formatting fix in
2362 + * the builder never reaches rows already on disk. Correcting on read means
2363 + * existing sites benefit without a migration.
2364 + *
2365 + * Covers non-ISO-8601 dates (#465) and WP locales in inLanguage, which must
2366 + * be a BCP-47 tag — en-US, not en_US (#473). Walks nested nodes so values
2367 + * inside author/publisher/@graph entries are covered too.
2368 + *
2369 + * Also decodes HTML entities in plain-text properties. Schema_Builder
2370 + * stored the block editor's `&amp;` as-is until 2.10.0, and nothing
2371 + * decodes JSON-LD downstream, so every deployed node built from post text
2372 + * published the entity literally.
2373 + *
2374 + * @since 1.16.0
2375 + * @since 2.10.0 Decodes entities in plain-text properties.
2376 + *
2377 + * @param array $schema Decoded schema data.
2378 + * @return array Normalised schema.
2379 + */
2380 + private function normalize_stored_schema(array $schema): array {
2381 + static $date_keys = [
2382 + 'datePublished', 'dateModified', 'dateCreated', 'uploadDate',
2383 + 'startDate', 'endDate', 'validFrom', 'validThrough', 'expires',
2384 + ];
2385 +
2386 + // Plain text in schema.org. Answer/HowToStep `text` is deliberately
2387 + // absent: Google reads Answer.text as HTML, where an entity is correct
2388 + // and decoding `&lt;` would turn escaped text into live markup.
2389 + static $text_keys = [
2390 + 'name', 'headline', 'alternativeHeadline', 'description',
2391 + 'reviewBody', 'about', 'abstract', 'caption',
2392 + ];
2393 +
2394 + foreach ($schema as $key => $value) {
2395 + if (is_array($value)) {
2396 + $schema[$key] = $this->normalize_stored_schema($value);
2397 + continue;
2398 + }
2399 +
2400 + if ('inLanguage' === $key && is_string($value) && '' !== $value) {
2401 + $schema[$key] = str_replace('_', '-', $value);
2402 + continue;
2403 + }
2404 +
2405 + // Decode only: a snapshot already truncated with an ellipsis must
2406 + // keep it, which the full Seo_Text::normalize_schema_text() would
2407 + // strip as an excerpt marker.
2408 + if (in_array($key, $text_keys, true) && is_string($value) && '' !== $value) {
2409 + $schema[$key] = \ThinkRank\Core\Seo_Text::decode_schema_entities($value);
2410 + continue;
2411 + }
2412 +
2413 + if (!in_array($key, $date_keys, true) || !is_string($value) || '' === $value) {
2414 + continue;
2415 + }
2416 +
2417 + // Already ISO 8601 — leave it alone.
2418 + if (preg_match('/^\d{4}-\d{2}-\d{2}T/', $value)) {
2419 + continue;
2420 + }
2421 +
2422 + $timestamp = strtotime($value);
2423 +
2424 + if (false !== $timestamp) {
2425 + $schema[$key] = (string) wp_date('c', $timestamp);
2426 + }
2427 + }
2428 +
2429 + return $schema;
2430 + }
2431 +
2432 + /**
1830 2433 * Clean up duplicate schemas in database
1831 2434 *
1832 2435 * @since 1.0.0
1833 2436 *
@@ -1879,28 +2482,131 @@
1879 2482 }
1880 2483
1881 2484 return $deleted ?: 0;
1882 2485 }
2486 +
1883 2487 /**
1884 - * Delete all existing schemas for a context before storing new ones
2488 + * Deactivate deployed schema rows for the given types.
1885 2489 *
2490 + * Deployment was insert-only, so anything ever deployed to a context stayed
2491 + * on the page forever — switching a post's schema type left the old one live
2492 + * and deactivating a saved schema did nothing (#464). Rows are deactivated
2493 + * rather than deleted so a later redeploy can revive them and so there is a
2494 + * trail of what was published.
2495 + *
2496 + * @since 1.16.0
2497 + *
2498 + * @param string $context_type Context type.
2499 + * @param int|null $context_id Context ID.
2500 + * @param string[] $schema_types Types to retire.
2501 + * @return int Number of rows deactivated.
2502 + */
2503 + private function retire_schema_types(string $context_type, ?int $context_id, array $schema_types): int {
2504 + $schema_types = array_values(array_filter(array_map('strval', $schema_types), 'strlen'));
2505 +
2506 + if (empty($schema_types)) {
2507 + return 0;
2508 + }
2509 +
2510 + global $wpdb;
2511 +
2512 + $table_name = $wpdb->prefix . 'thinkrank_seo_schema';
2513 + $placeholders = implode(', ', array_fill(0, count($schema_types), '%s'));
2514 +
2515 + if (null === $context_id) {
2516 + $sql = sprintf(
2517 + 'UPDATE %s SET is_active = 0 WHERE context_type = %%s AND context_id IS NULL AND schema_type IN (%s)',
2518 + $table_name,
2519 + $placeholders
2520 + );
2521 + $args = array_merge([$context_type], $schema_types);
2522 + } else {
2523 + $sql = sprintf(
2524 + 'UPDATE %s SET is_active = 0 WHERE context_type = %%s AND context_id = %%d AND schema_type IN (%s)',
2525 + $table_name,
2526 + $placeholders
2527 + );
2528 + $args = array_merge([$context_type, $context_id], $schema_types);
2529 + }
2530 +
2531 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Retiring deployed schema rows requires direct database access.
2532 + $updated = $wpdb->query(
2533 + $wpdb->prepare(
2534 + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is built from an internal table name and generated placeholders.
2535 + $sql,
2536 + $args
2537 + )
2538 + );
2539 +
2540 + if ($updated && $this->cache_manager) {
2541 + $this->cache_manager->invalidate_context_cache($context_type, $context_id);
2542 + }
2543 +
2544 + return (int) ($updated ?: 0);
2545 + }
2546 +
2547 + /**
2548 + * Retire deployed types that are no longer in the user's Schema Types selection.
2549 + *
2550 + * An empty selection means "auto-detect", so nothing is retired in that case.
2551 + *
2552 + * @since 1.16.0
2553 + *
2554 + * @param string $context_type Context type.
2555 + * @param int|null $context_id Context ID.
2556 + * @param array $enabled_types The user's selected types.
2557 + * @return int Number of rows deactivated.
2558 + */
2559 + private function retire_unselected_schema_types(string $context_type, ?int $context_id, array $enabled_types): int {
2560 + if (empty($enabled_types)) {
2561 + return 0;
2562 + }
2563 +
2564 + $deployed = array_keys($this->get_deployed_schemas($context_type, $context_id));
2565 + $stale = array_diff($deployed, $enabled_types);
2566 +
2567 + return $this->retire_schema_types($context_type, $context_id, $stale);
2568 + }
2569 +
2570 + /**
2571 + * Delete stored schemas for a context before storing new ones.
2572 + *
2573 + * `$schema_types` scopes the delete to the types actually being rewritten.
2574 + * Without it this wiped every type in the context, which silently destroyed
2575 + * deployed schema whenever a caller regenerated a subset — and
2576 + * auto_deploy_schema_on_settings_change() regenerates one type at a time
2577 + * (#454). Passing an empty array keeps the original clear-the-context
2578 + * behaviour for callers that genuinely rewrite everything.
2579 + *
1886 2580 * @since 1.0.0
1887 2581 *
1888 2582 * @param string $context_type Context type
1889 2583 * @param int|null $context_id Context ID
2584 + * @param string[] $schema_types Optional. Limit the delete to these types.
1890 2585 * @return int Number of schemas deleted
1891 2586 */
1892 - private function delete_existing_schemas(string $context_type, ?int $context_id): int {
2587 + private function delete_existing_schemas(string $context_type, ?int $context_id, array $schema_types = []): int {
1893 2588 global $wpdb;
1894 2589
1895 2590 $table_name = $wpdb->prefix . 'thinkrank_seo_schema';
1896 2591
2592 + // Build an optional `AND schema_type IN (…)` clause with one prepared
2593 + // placeholder per type, so the scoping cannot be injected through.
2594 + $type_clause = '';
2595 + $type_values = [];
2596 + $schema_types = array_values(array_filter(array_map('strval', $schema_types), 'strlen'));
2597 + if (!empty($schema_types)) {
2598 + $type_clause = ' AND schema_type IN (' . implode(', ', array_fill(0, count($schema_types), '%s')) . ')';
2599 + $type_values = $schema_types;
2600 + }
2601 +
1897 2602 if (null === $context_id) {
1898 2603 // Delete all schemas for NULL context_id
1899 2604 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema deletion requires direct database access
1900 2605 $sql = sprintf(
1901 - 'DELETE FROM %s WHERE context_type = %%s AND context_id IS NULL',
1902 - $table_name
2606 + 'DELETE FROM %s WHERE context_type = %%s AND context_id IS NULL%s',
2607 + $table_name,
2608 + $type_clause
1903 2609 );
1904 2610 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema deletion requires direct database access
1905 2611 $deleted = $wpdb->query(
1906 2612 $wpdb->prepare(
@@ -1905,9 +2611,9 @@
1905 2611 $deleted = $wpdb->query(
1906 2612 $wpdb->prepare(
1907 2613 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
1908 2614 $sql,
1909 - $context_type
2615 + array_merge([$context_type], $type_values)
1910 2616 )
1911 2617 );
1912 2618 } else {
1913 2619 // Delete all schemas for specific context_id
@@ -1912,10 +2618,11 @@
1912 2618 } else {
1913 2619 // Delete all schemas for specific context_id
1914 2620 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema deletion requires direct database access
1915 2621 $sql = sprintf(
1916 - 'DELETE FROM %s WHERE context_type = %%s AND context_id = %%d',
1917 - $table_name
2622 + 'DELETE FROM %s WHERE context_type = %%s AND context_id = %%d%s',
2623 + $table_name,
2624 + $type_clause
1918 2625 );
1919 2626 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema deletion requires direct database access
1920 2627 $deleted = $wpdb->query(
1921 2628 $wpdb->prepare(
@@ -1920,10 +2627,9 @@
1920 2627 $deleted = $wpdb->query(
1921 2628 $wpdb->prepare(
1922 2629 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
1923 2630 $sql,
1924 - $context_type,
1925 - $context_id
2631 + array_merge([$context_type, $context_id], $type_values)
1926 2632 )
1927 2633 );
1928 2634 }
1929 2635
@@ -2007,8 +2713,11 @@
2007 2713 'site_url' => home_url(),
2008 2714 'admin_email' => get_option('admin_email'),
2009 2715 'language' => get_locale(),
2010 2716 'timezone' => get_option('timezone_string'),
2717 + // Read by populate_website_schema(), so the deployed WebSite node
2718 + // carries the same alternateName as the default one (#692).
2719 + 'alternate_name' => $site_identity_settings['alternate_name'] ?? '',
2011 2720 'founded_date' => $site_identity_settings['founded_date'] ?? '',
2012 2721 'founder_name' => $site_identity_settings['founder_name'] ?? '',
2013 2722 'company_type' => $site_identity_settings['company_type'] ?? 'Organization',
2014 2723 // Site Identity assets