PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.9.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.9.0
2.9.0 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 All 50 releases
← All changes | includes/seo/class-schema-management-system.php +661 -51 2.0.0 → 2.9.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'],
@@ -309,8 +321,19 @@
309 321 */
310 322 private ?Schema_Cache_Manager $cache_manager = null;
311 323
312 324 /**
325 + * Whether the foreign-settings listener has been registered this request.
326 + *
327 + * Static because `thinkrank_seo_settings_saved` is a global hook — one
328 + * listener serves every instance. See the constructor for why (#463).
329 + *
330 + * @since 1.16.0
331 + * @var bool
332 + */
333 + private static bool $foreign_settings_listener_registered = false;
334 +
335 + /**
313 336 * Constructor
314 337 *
315 338 * @since 1.0.0
316 339 */
@@ -326,11 +349,117 @@
326 349 $this->initialize_schema_builder();
327 350
328 351 // Initialize Schema Cache Manager for performance optimization
329 352 $this->initialize_cache_manager();
353 +
354 + // LocalBusiness and Organization both read Business Info, which Site
355 + // Identity owns. Without this, editing an address or phone number never
356 + // refreshed the deployed schema (#455).
357 + //
358 + // Registered at most once per request. WordPress keys callbacks by
359 + // object hash, so binding $this here added a fresh listener for every
360 + // instance — and this class is constructed from inside the very callback
361 + // it registers, which doubled the listener count on every settings save
362 + // (#463). The guard is static because the hook itself is global.
363 + if (!self::$foreign_settings_listener_registered) {
364 + self::$foreign_settings_listener_registered = true;
365 + add_action('thinkrank_seo_settings_saved', [$this, 'refresh_schema_for_foreign_settings'], 10, 4);
366 + }
330 367 }
331 368
332 369 /**
370 + * Regenerate schema when another manager saves settings this schema reads.
371 + *
372 + * Site Identity owns the Business Info fields that feed LocalBusiness and
373 + * the Organization address/contactPoint, so a save there has to refresh the
374 + * deployed schema even though no schema setting changed.
375 + *
376 + * @since 2.0.2
377 + *
378 + * @param string $manager_type Settings category that was saved.
379 + * @param array $settings Settings that were written.
380 + * @param string $context_type Context type.
381 + * @param int|null $context_id Context ID.
382 + * @return void
383 + */
384 + public function refresh_schema_for_foreign_settings(
385 + string $manager_type,
386 + array $settings,
387 + string $context_type,
388 + ?int $context_id
389 + ): void {
390 + if ('site_identity' !== $manager_type) {
391 + return;
392 + }
393 +
394 + $business_keys = [
395 + 'business_name', 'business_type', 'business_address', 'business_city',
396 + 'business_state', 'business_postal_code', 'business_country',
397 + 'business_phone', 'business_email', 'business_hours',
398 + 'business_latitude', 'business_longitude', 'business_price_range',
399 + ];
400 +
401 + // Which deployed types a Site Identity key can invalidate. The business
402 + // block feeds LocalBusiness and Organization; alternate_name feeds the
403 + // WebSite node, which had no entry here at all — so editing it left the
404 + // deployed schema showing the previous value until something else
405 + // happened to redeploy (#692).
406 + $refresh_types = [];
407 +
408 + if (!empty(array_intersect_key($settings, array_flip($business_keys)))) {
409 + $refresh_types[] = 'LocalBusiness';
410 + $refresh_types[] = 'Organization';
411 + }
412 +
413 + if (array_key_exists('alternate_name', $settings)) {
414 + $refresh_types[] = 'WebSite';
415 + }
416 +
417 + if (empty($refresh_types)) {
418 + return;
419 + }
420 +
421 + $schema_settings = $this->get_settings($context_type, $context_id);
422 + if (empty($schema_settings['auto_deploy'])) {
423 + return;
424 + }
425 +
426 + // Only refresh types that are actually deployed, so this never adds a
427 + // type the admin did not enable.
428 + $deployed = array_keys((array) $this->get_deployed_schemas($context_type, $context_id));
429 + $affected = array_values(array_intersect($deployed, $refresh_types));
430 +
431 + if (empty($affected)) {
432 + return;
433 + }
434 +
435 + try {
436 + $generation = $this->generate_schema_markup($context_type, $context_id, $affected);
437 +
438 + // Deploy the types that validated, not all-or-nothing. Gating on
439 + // deployment_ready meant one invalid type blocked every valid one
440 + // in the same batch (#470).
441 + $deployable = [];
442 + foreach ($affected as $type) {
443 + if (!empty($generation['generated_schemas'][$type])
444 + && !empty($generation['validation_results'][$type]['is_valid'])
445 + ) {
446 + $deployable[$type] = $generation['generated_schemas'][$type];
447 + }
448 + }
449 +
450 + if (!empty($deployable)) {
451 + $this->deploy_schema_markup($context_type, $context_id, $deployable);
452 + }
453 + } catch (\Exception $e) {
454 + if (defined('WP_DEBUG') && WP_DEBUG) {
455 + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
456 + error_log('ThinkRank: Business Info schema refresh failed: ' . $e->getMessage());
457 + }
458 + }
459 + }
460 +
461 + /**
333 462 * Initialize Schema Builder
334 463 *
335 464 * @return void
336 465 */
@@ -356,10 +485,20 @@
356 485 require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-schema-cache-manager.php';
357 486 }
358 487
359 488 if (class_exists('ThinkRank\\SEO\\Schema_Cache_Manager')) {
360 - // Get cache duration from deployment config
489 + // Honour the stored cache_duration setting. It is exposed in
490 + // get_settings_schema() (min 300 / max 86400), validated, persisted
491 + // and surfaced through both abilities — but the cache manager was
492 + // always built from the hardcoded config value, so the setting had
493 + // no effect (#473). Falls back to the config default.
361 494 $cache_duration = $this->deployment_config['caching']['duration'] ?? 3600;
495 +
496 + $stored = $this->get_settings('site', null)['cache_duration'] ?? null;
497 + if (is_numeric($stored) && (int) $stored > 0) {
498 + $cache_duration = (int) $stored;
499 + }
500 +
362 501 $this->cache_manager = new Schema_Cache_Manager($cache_duration);
363 502 }
364 503 }
365 504
@@ -367,12 +506,19 @@
367 506 * Generate schema markup with comprehensive content analysis integration
368 507 *
369 508 * @since 1.0.0
370 509 *
510 + * Generation is read-only by default. Persisting the result is opt-in via
511 + * `$options['persist']`, because this method is also reached from the
512 + * front-end read path (get_output_data()) and from GET routes — where a
513 + * DELETE + INSERT would destroy the admin's deployed rows and publish
514 + * types nobody deployed (#460).
515 + *
371 516 * @param string $context_type Context type
372 517 * @param int|null $context_id Context ID
373 518 * @param array $schema_types Schema types to generate
374 - * @param array $options Generation options
519 + * @param array $options Generation options. Pass `persist => true`
520 + * from explicit write paths only.
375 521 * @return array Comprehensive schema generation results
376 522 */
377 523 public function generate_schema_markup(string $context_type, ?int $context_id, array $schema_types = [], array $options = []): array {
378 524 $generation = [
@@ -443,10 +589,16 @@
443 589
444 590 // Check deployment readiness
445 591 $generation['deployment_ready'] = $this->check_deployment_readiness($generation['validation_results']);
446 592
447 - // Store schema data
448 - $this->store_schema_data($context_type, $context_id, $generation);
593 + // Persistence belongs to deployment, not generation. Every write path
594 + // (refresh_schema_for_foreign_settings(), auto_deploy_schema_on_settings_change(),
595 + // the deploy route) calls deploy_schema_markup() straight after generating,
596 + // so nothing needs to opt in today — the flag exists to keep this an
597 + // explicit decision rather than an accident.
598 + if (!empty($options['persist'])) {
599 + $this->store_schema_data($context_type, $context_id, $generation);
600 + }
449 601
450 602 return $generation;
451 603 }
452 604
@@ -574,8 +726,24 @@
574 726
575 727 // Determine deployment method
576 728 $deployment['deployment_method'] = $this->determine_deployment_method($options);
577 729
730 + // When the caller owns the whole context — the user pressing Deploy, where
731 + // the payload is exactly what the preview showed — anything not in that
732 + // payload should come off the page (#464). Incremental callers such as
733 + // auto_deploy_schema_on_settings_change() pass only the types they
734 + // regenerated, so they must NOT retire the rest.
735 + if (!empty($options['authoritative'])) {
736 + $deployment['retired_schemas'] = $this->retire_schema_types(
737 + $context_type,
738 + $context_id,
739 + array_diff(
740 + array_keys($this->get_deployed_schemas($context_type, $context_id)),
741 + array_keys($schema_data)
742 + )
743 + );
744 + }
745 +
578 746 // Deploy each schema
579 747 foreach ($schema_data as $schema_type => $schema) {
580 748 $deploy_result = $this->deploy_single_schema($schema, $schema_type, $deployment['deployment_method'], $context_type, $context_id);
581 749 $deployment['deployed_schemas'][$schema_type] = $deploy_result;
@@ -661,9 +829,9 @@
661 829 }
662 830
663 831 return [
664 832 'validation_passed' => true,
665 - 'message' => __('Schema deployed and verified on the front end', 'thinkrank'),
833 + 'message' => __('Schema deployed and read back from storage', 'thinkrank'),
666 834 'missing_types' => []
667 835 ];
668 836 }
669 837
@@ -783,9 +951,11 @@
783 951 'validation_results' => [],
784 952 'rich_snippets_preview' => [],
785 953 'performance_data' => [],
786 954 'recommendations' => [],
787 - 'enabled' => true
955 + // Report the real setting. Hardcoding true here told every consumer
956 + // the feature was on even when the master switch was off (#461).
957 + 'enabled' => (bool) ($settings['enabled'] ?? true)
788 958 ];
789 959
790 960 // Get enabled schema types
791 961 $enabled_types = $settings['enabled_schema_types'] ?? [];
@@ -1039,8 +1209,47 @@
1039 1209 return home_url('/wp-content/plugins/thinkrank/assets/images/default-logo.jpg');
1040 1210 }
1041 1211
1042 1212 /**
1213 + * Schema keys outside the shared config defaults.
1214 + *
1215 + * @since 2.0.1
1216 + *
1217 + * @return string[]
1218 + */
1219 + protected function additional_setting_keys(): array {
1220 + return [
1221 + 'enable_article_schema', 'enable_product_schema',
1222 + 'enable_faq_schema', 'enable_howto_schema',
1223 + ];
1224 + }
1225 +
1226 + /**
1227 + * Per-entity schema fields are an open set.
1228 + *
1229 + * Each schema type the UI can edit contributes its own field family —
1230 + * organization_*, person_*, website_*, business_*, software_*, howto_* —
1231 + * and a new type adds another. The families this manager owns are matched
1232 + * rather than enumerated, so adding a form does not silently start
1233 + * dropping its fields (#452).
1234 + *
1235 + * @since 2.0.1
1236 + *
1237 + * @return string[]
1238 + */
1239 + protected function dynamic_setting_key_patterns(): array {
1240 + return [
1241 + '/^organization_[a-z0-9_]+$/',
1242 + '/^person_[a-z0-9_]+$/',
1243 + '/^website_[a-z0-9_]+$/',
1244 + '/^business_[a-z0-9_]+$/',
1245 + '/^software_[a-z0-9_]+$/',
1246 + '/^howto_[a-z0-9_]+$/',
1247 + '/^product_[a-z0-9_]+$/',
1248 + ];
1249 + }
1250 +
1251 + /**
1043 1252 * Get default settings for a context type (implements interface)
1044 1253 *
1045 1254 * @since 1.0.0
1046 1255 *
@@ -1132,38 +1341,59 @@
1132 1341 if ($this->has_person_settings_changed($settings)) {
1133 1342 $schema_types_to_regenerate[] = 'Person';
1134 1343 }
1135 1344
1345 + // Honour the user's Schema Types selection. Without this the payload
1346 + // shape alone decided what shipped, so every save deployed all four
1347 + // types — including ones the user had explicitly deselected (#461).
1348 + // An empty selection means "auto", so only filter when one is set.
1349 + $enabled_types = $settings['enabled_schema_types'] ?? $this->get_settings($context_type, $context_id)['enabled_schema_types'] ?? [];
1350 +
1351 + if (!empty($enabled_types) && is_array($enabled_types)) {
1352 + $schema_types_to_regenerate = array_values(
1353 + array_intersect($schema_types_to_regenerate, $enabled_types)
1354 + );
1355 + }
1356 +
1357 + // Types that were deployed but are no longer wanted must come back off
1358 + // the page — deployment used to be additive-only (#464).
1359 + $this->retire_unselected_schema_types($context_type, $context_id, $enabled_types);
1360 +
1136 1361 // If no schema types need regeneration, return early
1137 1362 if (empty($schema_types_to_regenerate)) {
1138 1363 return;
1139 1364 }
1140 1365
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 - );
1366 + // Generate every affected type in ONE call. Generating them one at a
1367 + // time re-entered store_schema_data() per type, and each pass replaced
1368 + // the rows written by the previous one, so only the last type survived
1369 + // (#454). One batch also means one delete and one cache flush.
1370 + try {
1371 + $generation_result = $this->generate_schema_markup(
1372 + $context_type,
1373 + $context_id,
1374 + $schema_types_to_regenerate
1375 + );
1150 1376
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 - );
1377 + $deployable = [];
1378 + foreach ($schema_types_to_regenerate as $schema_type) {
1379 + // Only deploy what validated — see #470.
1380 + if (!empty($generation_result['generated_schemas'][$schema_type])
1381 + && !empty($generation_result['validation_results'][$schema_type]['is_valid'])
1382 + ) {
1383 + $deployable[$schema_type] = $generation_result['generated_schemas'][$schema_type];
1158 1384 }
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 1385 }
1386 +
1387 + if (!empty($deployable)) {
1388 + $this->deploy_schema_markup($context_type, $context_id, $deployable);
1389 + }
1390 + } catch (\Exception $e) {
1391 + // Log error but don't fail the settings save
1392 + if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
1393 + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
1394 + error_log('ThinkRank: Auto-deploy failed for ' . implode(', ', $schema_types_to_regenerate) . ': ' . $e->getMessage());
1395 + }
1166 1396 }
1167 1397 }
1168 1398
1169 1399 /**
@@ -1201,9 +1431,15 @@
1201 1431 * @param array $settings Updated settings
1202 1432 * @return bool True if website settings changed
1203 1433 */
1204 1434 private function has_website_settings_changed(array $settings): bool {
1205 - $website_keys = ['site_name', 'site_description', 'site_url'];
1435 + // These are the keys the Website tab actually stores. It previously
1436 + // looked for site_name/site_description/site_url, which belong to Site
1437 + // Identity and never appear in a schema settings payload — so WebSite
1438 + // schema never auto-deployed no matter what was edited (#455).
1439 + $website_keys = [
1440 + 'website_name', 'website_url', 'website_description', 'website_author',
1441 + ];
1206 1442
1207 1443 foreach ($website_keys as $key) {
1208 1444 if (isset($settings[$key])) {
1209 1445 return true;
@@ -1221,11 +1457,20 @@
1221 1457 * @param array $settings Updated settings
1222 1458 * @return bool True if business settings changed
1223 1459 */
1224 1460 private function has_business_settings_changed(array $settings): bool {
1461 + // Only the keys this manager actually stores. business_name/address/
1462 + // phone/hours live in the site_identity category and never reach a
1463 + // schema settings save, so keying off them meant LocalBusiness never
1464 + // auto-deployed (#455). Edits to those fields refresh LocalBusiness
1465 + // through the Site Identity save path instead — see
1466 + // refresh_schema_for_foreign_settings().
1225 1467 $business_keys = [
1226 - 'business_name', 'business_type', 'business_address', 'business_phone',
1227 - 'business_hours', 'business_price_range'
1468 + 'enable_local_business',
1469 + 'business_price_range',
1470 + 'business_geo_latitude',
1471 + 'business_geo_longitude',
1472 + 'business_opening_hours',
1228 1473 ];
1229 1474
1230 1475 foreach ($business_keys as $key) {
1231 1476 if (isset($settings[$key])) {
@@ -1269,8 +1514,29 @@
1269 1514 $detected_types = [];
1270 1515
1271 1516 switch ($context_type) {
1272 1517 case 'site':
1518 + // The admin's Schema Types selection is the answer to "what
1519 + // does this site need"; detection is only the fallback for an
1520 + // install that has not chosen yet (#456).
1521 + $settings = $this->get_settings($context_type, $context_id);
1522 + $enabled = array_values(array_filter(
1523 + array_map('strval', (array) ($settings['enabled_schema_types'] ?? [])),
1524 + 'strlen'
1525 + ));
1526 +
1527 + // Drop stale names the factory no longer registers rather than
1528 + // handing them to the builder to silently skip.
1529 + $enabled = array_values(array_filter(
1530 + $enabled,
1531 + fn($type) => isset($this->schema_types[$type])
1532 + ));
1533 +
1534 + if (!empty($enabled)) {
1535 + $detected_types = $enabled;
1536 + break;
1537 + }
1538 +
1273 1539 $detected_types = ['Organization'];
1274 1540 // Check if it's a local business
1275 1541 if ($this->is_local_business()) {
1276 1542 $detected_types[] = 'LocalBusiness';
@@ -1336,16 +1602,20 @@
1336 1602 if ($post) {
1337 1603 $content_data = [
1338 1604 'title' => $post->post_title,
1339 1605 'url' => get_permalink($post->ID),
1340 - 'excerpt' => $post->post_excerpt ?: wp_trim_words($post->post_content, 30),
1606 + 'excerpt' => $post->post_excerpt ?: \ThinkRank\Core\Seo_Text::trim_words($post->post_content, 30),
1341 1607 'content' => $post->post_content,
1342 1608 'author' => [
1343 1609 'name' => get_the_author_meta('display_name', $post->post_author),
1344 1610 'url' => get_author_posts_url($post->post_author)
1345 1611 ],
1346 - 'date' => $post->post_date,
1347 - 'modified' => $post->post_modified,
1612 + // ISO 8601 with offset. post_date/post_modified are raw
1613 + // MySQL columns in site-local time with no timezone, which
1614 + // Google rejects as "Invalid value in field datePublished"
1615 + // and drops the Article rich result (#465).
1616 + 'date' => get_the_date('c', $post),
1617 + 'modified' => get_the_modified_date('c', $post),
1348 1618 'image' => get_the_post_thumbnail_url($post->ID, 'full'),
1349 1619 'focus_keywords' => Focus_Keywords::get($post->ID),
1350 1620 'business_data' => $this->get_business_data_from_local_seo(),
1351 1621 'site_data' => $this->get_site_data_for_schema(),
@@ -1440,10 +1710,16 @@
1440 1710 global $wpdb;
1441 1711
1442 1712 $table_name = $wpdb->prefix . 'thinkrank_seo_schema';
1443 1713
1444 - // First, delete all existing schemas for this context to ensure clean storage
1445 - $this->delete_existing_schemas($context_type, $context_id);
1714 + // Replace only the types in this batch. Clearing the whole context
1715 + // destroyed types the caller never asked about — and callers do
1716 + // regenerate a subset, one type at a time (#454).
1717 + $generated_types = array_keys($generation['generated_schemas'] ?? []);
1718 + if (empty($generated_types)) {
1719 + return false;
1720 + }
1721 + $this->delete_existing_schemas($context_type, $context_id, $generated_types);
1446 1722
1447 1723 foreach ($generation['generated_schemas'] as $schema_type => $schema_data) {
1448 1724 // Prepare schema data with validation status embedded
1449 1725 $schema_data_with_validation = $schema_data;
@@ -1459,9 +1735,13 @@
1459 1735 'context_id' => $context_id,
1460 1736 'schema_type' => $schema_type,
1461 1737 'schema_data' => wp_json_encode($schema_data_with_validation),
1462 1738 'validation_status' => $generation['validation_results'][$schema_type]['is_valid'] ? 'valid' : 'invalid',
1463 - 'is_active' => $generation['deployment_ready'] ? 1 : 0
1739 + // Per-type, not batch-wide. deployment_ready is only true when
1740 + // EVERY type in the batch validated, so one invalid type (a site
1741 + // with no Business Info makes LocalBusiness invalid) deactivated
1742 + // all the valid ones alongside it (#470).
1743 + 'is_active' => !empty($generation['validation_results'][$schema_type]['is_valid']) ? 1 : 0
1464 1744 ];
1465 1745
1466 1746 // Insert new schema (existing ones were already deleted)
1467 1747 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema insertion requires direct database access
@@ -1792,11 +2072,14 @@
1792 2072 ARRAY_A
1793 2073 );
1794 2074 }
1795 2075
1796 - if (empty($deployed_schemas)) {
1797 - return [];
1798 - }
2076 + // Deliberately no early return on an empty result: it has to reach the
2077 + // cache write below. Most URLs have no deployed schema, so gating the
2078 + // write on a non-empty result made the majority of front-end requests
2079 + // permanent cache misses, re-running a ROW_NUMBER() OVER (PARTITION BY
2080 + // ...) query with two filesorts on every pageview (#392).
2081 + $deployed_schemas = $deployed_schemas ?: [];
1799 2082
1800 2083 // Process schemas for return
1801 2084 $processed_schemas = [];
1802 2085 foreach ($deployed_schemas as $deployed_schema) {
@@ -1808,8 +2091,26 @@
1808 2091 if (isset($schema_data['_validation'])) {
1809 2092 unset($schema_data['_validation']);
1810 2093 }
1811 2094
2095 + // Deployed schema is a snapshot, so rows written before #465
2096 + // still carry raw MySQL datetimes. Normalise on read so the
2097 + // fix reaches existing sites without a migration.
2098 + $schema_data = $this->normalize_stored_schema($schema_data);
2099 +
2100 + // The permalink was frozen at deploy time, so schema deployed
2101 + // while a post was a draft advertised "?p=123" as both url and
2102 + // mainEntityOfPage forever — contradicting the node's own @id
2103 + // and the canonical (#470). Resolve it live instead.
2104 + $schema_data = $this->refresh_schema_permalink($schema_data, $context_type, $context_id);
2105 +
2106 + // schema.org types `sameAs`, `url`, `logo` and `image` as URLs,
2107 + // but the form stored whatever was typed, so free text entered
2108 + // in a social-profile field shipped as a sameAs member and made
2109 + // the whole entity invalid (#480). Drop bad values on read, so
2110 + // existing sites stop emitting them without a migration.
2111 + $schema_data = $this->filter_entity_urls($schema_data);
2112 +
1812 2113 $processed_schemas[$schema_type] = [
1813 2114 'data' => $schema_data,
1814 2115 'method' => 'json_ld', // Default method
1815 2116 'type' => $schema_type
@@ -1816,10 +2117,13 @@
1816 2117 ];
1817 2118 }
1818 2119 }
1819 2120
1820 - // CACHE LAYER: Store result in cache for future requests
1821 - if ($this->cache_manager && !empty($processed_schemas)) {
2121 + // CACHE LAYER: Store result in cache for future requests — including
2122 + // an empty one. Cache_Manager::set() wraps the payload in a metadata
2123 + // envelope, so an empty result is still stored as a truthy value and
2124 + // reads back as a hit rather than a miss (#392).
2125 + if ($this->cache_manager) {
1822 2126 $cache_key = $this->cache_manager->generate_deployed_schemas_key($context_type, $context_id);
1823 2127 $this->cache_manager->set($cache_key, $processed_schemas);
1824 2128 }
1825 2129
@@ -1826,8 +2130,208 @@
1826 2130 return $processed_schemas;
1827 2131 }
1828 2132
1829 2133 /**
2134 + * Properties schema.org defines as URLs.
2135 + *
2136 + * @since 2.0.2
2137 + * @var string[]
2138 + */
2139 + private const URL_PROPERTIES = ['sameAs', 'url', 'logo', 'image'];
2140 +
2141 + /**
2142 + * Whether a value is a URL safe to publish in structured data.
2143 + *
2144 + * @since 2.0.2
2145 + *
2146 + * @param mixed $url Candidate value.
2147 + * @return bool
2148 + */
2149 + private function is_publishable_url($url): bool {
2150 + if (!is_string($url) || '' === trim($url)) {
2151 + return false;
2152 + }
2153 +
2154 + if (!filter_var($url, FILTER_VALIDATE_URL)) {
2155 + return false;
2156 + }
2157 +
2158 + $scheme = wp_parse_url($url, PHP_URL_SCHEME);
2159 +
2160 + return in_array(strtolower((string) $scheme), ['http', 'https'], true);
2161 + }
2162 +
2163 + /**
2164 + * Drop values that are not URLs from URL-typed properties.
2165 + *
2166 + * An absent property is valid; one holding free text is not, and it can
2167 + * invalidate the entity around it. Nested objects (`logo` and `image` are
2168 + * frequently ImageObjects) are walked so a bad `url` inside one is caught
2169 + * too. A property left with nothing is removed rather than emitted empty.
2170 + *
2171 + * @since 2.0.2
2172 + *
2173 + * @param array $schema Decoded schema data.
2174 + * @return array Schema carrying only publishable URLs.
2175 + */
2176 + private function filter_entity_urls(array $schema): array {
2177 + foreach ($schema as $key => $value) {
2178 + if (is_array($value) && !in_array($key, self::URL_PROPERTIES, true)) {
2179 + $schema[$key] = $this->filter_entity_urls($value);
2180 + continue;
2181 + }
2182 +
2183 + if (!in_array($key, self::URL_PROPERTIES, true)) {
2184 + continue;
2185 + }
2186 +
2187 + // A nested object (ImageObject and friends) carries its own url.
2188 + if (is_array($value) && isset($value['@type'])) {
2189 + $schema[$key] = $this->filter_entity_urls($value);
2190 + continue;
2191 + }
2192 +
2193 + if (is_array($value)) {
2194 + $kept = [];
2195 +
2196 + foreach ($value as $item) {
2197 + if (is_array($item)) {
2198 + $kept[] = $this->filter_entity_urls($item);
2199 + } elseif ($this->is_publishable_url($item)) {
2200 + $kept[] = $item;
2201 + }
2202 + }
2203 +
2204 + if ([] === $kept) {
2205 + unset($schema[$key]);
2206 + } else {
2207 + $schema[$key] = array_values($kept);
2208 + }
2209 +
2210 + continue;
2211 + }
2212 +
2213 + if (!$this->is_publishable_url($value)) {
2214 + unset($schema[$key]);
2215 + }
2216 + }
2217 +
2218 + return $schema;
2219 + }
2220 +
2221 + /**
2222 + * Schema types whose `url` identifies the entity, not the page.
2223 + *
2224 + * On a Person or an Organization, `url` is that entity's own website, so
2225 + * overwriting it with the permalink of whichever post the schema happens to
2226 + * be deployed on is simply wrong. It also breaks graph assembly: the site
2227 + * identity emits the same entity with its real `url`, and once the two
2228 + * copies disagree they can no longer be recognised as one entity (#479).
2229 + *
2230 + * @since 2.0.2
2231 + * @var string[]
2232 + */
2233 + private const ENTITY_URL_TYPES = ['Person', 'Organization', 'LocalBusiness'];
2234 +
2235 + /**
2236 + * Replace a stored permalink snapshot with the post's live permalink.
2237 + *
2238 + * Only touches `url` and `mainEntityOfPage`, and only for post-like
2239 + * contexts where a permalink actually exists. Identity entities are
2240 + * exempt from the `url` rewrite — see self::ENTITY_URL_TYPES.
2241 + *
2242 + * @since 1.16.0
2243 + *
2244 + * @param array $schema Decoded schema data.
2245 + * @param string $context_type Context type.
2246 + * @param int|null $context_id Context ID.
2247 + * @return array Schema with a current permalink.
2248 + */
2249 + private function refresh_schema_permalink(array $schema, string $context_type, ?int $context_id): array {
2250 + if ('site' === $context_type || empty($context_id)) {
2251 + return $schema;
2252 + }
2253 +
2254 + $permalink = get_permalink($context_id);
2255 +
2256 + if (!$permalink) {
2257 + return $schema;
2258 + }
2259 +
2260 + $type = $schema['@type'] ?? '';
2261 + $type = is_array($type) ? reset($type) : $type;
2262 + $is_entity = in_array((string) $type, self::ENTITY_URL_TYPES, true);
2263 +
2264 + if (isset($schema['url']) && !$is_entity) {
2265 + $schema['url'] = $permalink;
2266 + }
2267 +
2268 + if (isset($schema['mainEntityOfPage'])) {
2269 + if (is_array($schema['mainEntityOfPage'])) {
2270 + if (isset($schema['mainEntityOfPage']['@id'])) {
2271 + $schema['mainEntityOfPage']['@id'] = $permalink;
2272 + }
2273 + } else {
2274 + $schema['mainEntityOfPage'] = $permalink;
2275 + }
2276 + }
2277 +
2278 + return $schema;
2279 + }
2280 +
2281 + /**
2282 + * Normalise properties that stored snapshots may hold in a stale format.
2283 + *
2284 + * Deployed schema is written once and read forever, so a formatting fix in
2285 + * the builder never reaches rows already on disk. Correcting on read means
2286 + * existing sites benefit without a migration.
2287 + *
2288 + * Covers non-ISO-8601 dates (#465) and WP locales in inLanguage, which must
2289 + * be a BCP-47 tag — en-US, not en_US (#473). Walks nested nodes so values
2290 + * inside author/publisher/@graph entries are covered too.
2291 + *
2292 + * @since 1.16.0
2293 + *
2294 + * @param array $schema Decoded schema data.
2295 + * @return array Normalised schema.
2296 + */
2297 + private function normalize_stored_schema(array $schema): array {
2298 + static $date_keys = [
2299 + 'datePublished', 'dateModified', 'dateCreated', 'uploadDate',
2300 + 'startDate', 'endDate', 'validFrom', 'validThrough', 'expires',
2301 + ];
2302 +
2303 + foreach ($schema as $key => $value) {
2304 + if (is_array($value)) {
2305 + $schema[$key] = $this->normalize_stored_schema($value);
2306 + continue;
2307 + }
2308 +
2309 + if ('inLanguage' === $key && is_string($value) && '' !== $value) {
2310 + $schema[$key] = str_replace('_', '-', $value);
2311 + continue;
2312 + }
2313 +
2314 + if (!in_array($key, $date_keys, true) || !is_string($value) || '' === $value) {
2315 + continue;
2316 + }
2317 +
2318 + // Already ISO 8601 — leave it alone.
2319 + if (preg_match('/^\d{4}-\d{2}-\d{2}T/', $value)) {
2320 + continue;
2321 + }
2322 +
2323 + $timestamp = strtotime($value);
2324 +
2325 + if (false !== $timestamp) {
2326 + $schema[$key] = (string) wp_date('c', $timestamp);
2327 + }
2328 + }
2329 +
2330 + return $schema;
2331 + }
2332 +
2333 + /**
1830 2334 * Clean up duplicate schemas in database
1831 2335 *
1832 2336 * @since 1.0.0
1833 2337 *
@@ -1879,28 +2383,131 @@
1879 2383 }
1880 2384
1881 2385 return $deleted ?: 0;
1882 2386 }
2387 +
1883 2388 /**
1884 - * Delete all existing schemas for a context before storing new ones
2389 + * Deactivate deployed schema rows for the given types.
1885 2390 *
2391 + * Deployment was insert-only, so anything ever deployed to a context stayed
2392 + * on the page forever — switching a post's schema type left the old one live
2393 + * and deactivating a saved schema did nothing (#464). Rows are deactivated
2394 + * rather than deleted so a later redeploy can revive them and so there is a
2395 + * trail of what was published.
2396 + *
2397 + * @since 1.16.0
2398 + *
2399 + * @param string $context_type Context type.
2400 + * @param int|null $context_id Context ID.
2401 + * @param string[] $schema_types Types to retire.
2402 + * @return int Number of rows deactivated.
2403 + */
2404 + private function retire_schema_types(string $context_type, ?int $context_id, array $schema_types): int {
2405 + $schema_types = array_values(array_filter(array_map('strval', $schema_types), 'strlen'));
2406 +
2407 + if (empty($schema_types)) {
2408 + return 0;
2409 + }
2410 +
2411 + global $wpdb;
2412 +
2413 + $table_name = $wpdb->prefix . 'thinkrank_seo_schema';
2414 + $placeholders = implode(', ', array_fill(0, count($schema_types), '%s'));
2415 +
2416 + if (null === $context_id) {
2417 + $sql = sprintf(
2418 + 'UPDATE %s SET is_active = 0 WHERE context_type = %%s AND context_id IS NULL AND schema_type IN (%s)',
2419 + $table_name,
2420 + $placeholders
2421 + );
2422 + $args = array_merge([$context_type], $schema_types);
2423 + } else {
2424 + $sql = sprintf(
2425 + 'UPDATE %s SET is_active = 0 WHERE context_type = %%s AND context_id = %%d AND schema_type IN (%s)',
2426 + $table_name,
2427 + $placeholders
2428 + );
2429 + $args = array_merge([$context_type, $context_id], $schema_types);
2430 + }
2431 +
2432 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Retiring deployed schema rows requires direct database access.
2433 + $updated = $wpdb->query(
2434 + $wpdb->prepare(
2435 + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is built from an internal table name and generated placeholders.
2436 + $sql,
2437 + $args
2438 + )
2439 + );
2440 +
2441 + if ($updated && $this->cache_manager) {
2442 + $this->cache_manager->invalidate_context_cache($context_type, $context_id);
2443 + }
2444 +
2445 + return (int) ($updated ?: 0);
2446 + }
2447 +
2448 + /**
2449 + * Retire deployed types that are no longer in the user's Schema Types selection.
2450 + *
2451 + * An empty selection means "auto-detect", so nothing is retired in that case.
2452 + *
2453 + * @since 1.16.0
2454 + *
2455 + * @param string $context_type Context type.
2456 + * @param int|null $context_id Context ID.
2457 + * @param array $enabled_types The user's selected types.
2458 + * @return int Number of rows deactivated.
2459 + */
2460 + private function retire_unselected_schema_types(string $context_type, ?int $context_id, array $enabled_types): int {
2461 + if (empty($enabled_types)) {
2462 + return 0;
2463 + }
2464 +
2465 + $deployed = array_keys($this->get_deployed_schemas($context_type, $context_id));
2466 + $stale = array_diff($deployed, $enabled_types);
2467 +
2468 + return $this->retire_schema_types($context_type, $context_id, $stale);
2469 + }
2470 +
2471 + /**
2472 + * Delete stored schemas for a context before storing new ones.
2473 + *
2474 + * `$schema_types` scopes the delete to the types actually being rewritten.
2475 + * Without it this wiped every type in the context, which silently destroyed
2476 + * deployed schema whenever a caller regenerated a subset — and
2477 + * auto_deploy_schema_on_settings_change() regenerates one type at a time
2478 + * (#454). Passing an empty array keeps the original clear-the-context
2479 + * behaviour for callers that genuinely rewrite everything.
2480 + *
1886 2481 * @since 1.0.0
1887 2482 *
1888 2483 * @param string $context_type Context type
1889 2484 * @param int|null $context_id Context ID
2485 + * @param string[] $schema_types Optional. Limit the delete to these types.
1890 2486 * @return int Number of schemas deleted
1891 2487 */
1892 - private function delete_existing_schemas(string $context_type, ?int $context_id): int {
2488 + private function delete_existing_schemas(string $context_type, ?int $context_id, array $schema_types = []): int {
1893 2489 global $wpdb;
1894 2490
1895 2491 $table_name = $wpdb->prefix . 'thinkrank_seo_schema';
1896 2492
2493 + // Build an optional `AND schema_type IN (…)` clause with one prepared
2494 + // placeholder per type, so the scoping cannot be injected through.
2495 + $type_clause = '';
2496 + $type_values = [];
2497 + $schema_types = array_values(array_filter(array_map('strval', $schema_types), 'strlen'));
2498 + if (!empty($schema_types)) {
2499 + $type_clause = ' AND schema_type IN (' . implode(', ', array_fill(0, count($schema_types), '%s')) . ')';
2500 + $type_values = $schema_types;
2501 + }
2502 +
1897 2503 if (null === $context_id) {
1898 2504 // Delete all schemas for NULL context_id
1899 2505 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema deletion requires direct database access
1900 2506 $sql = sprintf(
1901 - 'DELETE FROM %s WHERE context_type = %%s AND context_id IS NULL',
1902 - $table_name
2507 + 'DELETE FROM %s WHERE context_type = %%s AND context_id IS NULL%s',
2508 + $table_name,
2509 + $type_clause
1903 2510 );
1904 2511 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema deletion requires direct database access
1905 2512 $deleted = $wpdb->query(
1906 2513 $wpdb->prepare(
@@ -1905,9 +2512,9 @@
1905 2512 $deleted = $wpdb->query(
1906 2513 $wpdb->prepare(
1907 2514 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
1908 2515 $sql,
1909 - $context_type
2516 + array_merge([$context_type], $type_values)
1910 2517 )
1911 2518 );
1912 2519 } else {
1913 2520 // Delete all schemas for specific context_id
@@ -1912,10 +2519,11 @@
1912 2519 } else {
1913 2520 // Delete all schemas for specific context_id
1914 2521 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema deletion requires direct database access
1915 2522 $sql = sprintf(
1916 - 'DELETE FROM %s WHERE context_type = %%s AND context_id = %%d',
1917 - $table_name
2523 + 'DELETE FROM %s WHERE context_type = %%s AND context_id = %%d%s',
2524 + $table_name,
2525 + $type_clause
1918 2526 );
1919 2527 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema deletion requires direct database access
1920 2528 $deleted = $wpdb->query(
1921 2529 $wpdb->prepare(
@@ -1920,10 +2528,9 @@
1920 2528 $deleted = $wpdb->query(
1921 2529 $wpdb->prepare(
1922 2530 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
1923 2531 $sql,
1924 - $context_type,
1925 - $context_id
2532 + array_merge([$context_type, $context_id], $type_values)
1926 2533 )
1927 2534 );
1928 2535 }
1929 2536
@@ -2007,8 +2614,11 @@
2007 2614 'site_url' => home_url(),
2008 2615 'admin_email' => get_option('admin_email'),
2009 2616 'language' => get_locale(),
2010 2617 'timezone' => get_option('timezone_string'),
2618 + // Read by populate_website_schema(), so the deployed WebSite node
2619 + // carries the same alternateName as the default one (#692).
2620 + 'alternate_name' => $site_identity_settings['alternate_name'] ?? '',
2011 2621 'founded_date' => $site_identity_settings['founded_date'] ?? '',
2012 2622 'founder_name' => $site_identity_settings['founder_name'] ?? '',
2013 2623 'company_type' => $site_identity_settings['company_type'] ?? 'Organization',
2014 2624 // Site Identity assets