| 1 |
<?php |
| 2 |
/** |
| 3 |
* Site Health test for JSON-LD emitted by other plugins. |
| 4 |
* |
| 5 |
* @package ThinkRank\Diagnostics |
| 6 |
*/ |
| 7 |
|
| 8 |
declare(strict_types=1); |
| 9 |
|
| 10 |
namespace ThinkRank\Diagnostics; |
| 11 |
|
| 12 |
if (!defined('ABSPATH')) { |
| 13 |
exit; |
| 14 |
} |
| 15 |
|
| 16 |
/** |
| 17 |
* Surfaces foreign JSON-LD as a Site Health test. |
| 18 |
* |
| 19 |
* Registered async, so the loopback fetch Foreign_Schema_Detector performs runs |
| 20 |
* from the browser after the Site Health screen has painted, instead of adding |
| 21 |
* up to ten seconds to the page load. The alternative shape considered in #447 |
| 22 |
* — buffering the front end and scanning every response — was rejected: it puts |
| 23 |
* the cost on every anonymous pageview, which is the mistake #402 documents. |
| 24 |
* |
| 25 |
* The test reports. It never merges, rewrites or suppresses another plugin's |
| 26 |
* output. |
| 27 |
* |
| 28 |
* @since 2.9.0 |
| 29 |
*/ |
| 30 |
class Schema_Conflict_Health_Check { |
| 31 |
|
| 32 |
/** |
| 33 |
* Test identifier, also the `health-check-{test}` AJAX action suffix. |
| 34 |
* |
| 35 |
* Dashes only, no underscores. Core builds the AJAX action from the test id |
| 36 |
* with a *string* replace — `'health-check-' + this.test.replace( '_', '-' )` |
| 37 |
* in wp-admin/js/site-health.js — and a string pattern replaces only the |
| 38 |
* first match. Every core async test id has at most one underscore, so the |
| 39 |
* single replace is enough there. An id with two (`thinkrank_schema_conflicts`) |
| 40 |
* became `health-check-thinkrank-schema_conflicts` in the browser while we |
| 41 |
* registered `health-check-thinkrank_schema_conflicts`, so admin-ajax |
| 42 |
* answered `0`, core discarded the invalid payload and the test silently |
| 43 |
* never appeared on the Site Health screen. |
| 44 |
*/ |
| 45 |
private const TEST = 'thinkrank-schema-conflicts'; |
| 46 |
|
| 47 |
/** |
| 48 |
* Capability WordPress itself requires to view Site Health results. |
| 49 |
*/ |
| 50 |
private const CAPABILITY = 'view_site_health_checks'; |
| 51 |
|
| 52 |
/** |
| 53 |
* Register the test. |
| 54 |
* |
| 55 |
* @return void |
| 56 |
*/ |
| 57 |
public function init(): void { |
| 58 |
add_filter('site_status_tests', [$this, 'register_test']); |
| 59 |
add_action('wp_ajax_health-check-' . self::TEST, [$this, 'ajax_run']); |
| 60 |
|
| 61 |
// A plugin going active or inactive is exactly the event that changes |
| 62 |
// the answer, so the cached scan must not outlive it. |
| 63 |
add_action('activated_plugin', [Foreign_Schema_Detector::class, 'flush_cache']); |
| 64 |
add_action('deactivated_plugin', [Foreign_Schema_Detector::class, 'flush_cache']); |
| 65 |
} |
| 66 |
|
| 67 |
/** |
| 68 |
* Add the test to the Site Health registry. |
| 69 |
* |
| 70 |
* @param array<string, array<string, mixed>> $tests Registered tests. |
| 71 |
* @return array<string, array<string, mixed>> |
| 72 |
*/ |
| 73 |
public function register_test(array $tests): array { |
| 74 |
$tests['async'][self::TEST] = [ |
| 75 |
'label' => __('Schema markup from other plugins', 'thinkrank'), |
| 76 |
'test' => self::TEST, |
| 77 |
'has_rest' => false, |
| 78 |
'async_direct_test' => [$this, 'run'], |
| 79 |
]; |
| 80 |
|
| 81 |
return $tests; |
| 82 |
} |
| 83 |
|
| 84 |
/** |
| 85 |
* AJAX entry point for the async test. |
| 86 |
* |
| 87 |
* @return void |
| 88 |
*/ |
| 89 |
public function ajax_run(): void { |
| 90 |
check_ajax_referer('health-check-site-status'); |
| 91 |
|
| 92 |
if (!current_user_can(self::CAPABILITY)) { |
| 93 |
wp_send_json_error(null, 403); |
| 94 |
} |
| 95 |
|
| 96 |
wp_send_json_success($this->run()); |
| 97 |
} |
| 98 |
|
| 99 |
/** |
| 100 |
* Run the scan and shape it as a Site Health result. |
| 101 |
* |
| 102 |
* @return array<string, mixed> |
| 103 |
*/ |
| 104 |
public function run(): array { |
| 105 |
$report = (new Foreign_Schema_Detector())->scan(); |
| 106 |
|
| 107 |
if (!empty($report['error'])) { |
| 108 |
return $this->result( |
| 109 |
'recommended', |
| 110 |
__('ThinkRank could not check this site for duplicate schema markup', 'thinkrank'), |
| 111 |
'<p>' . sprintf( |
| 112 |
/* translators: 1: scanned URL, 2: error message. */ |
| 113 |
esc_html__('ThinkRank tried to read %1$s from this server and could not: %2$s Duplicate schema markup from another plugin would go unnoticed until this check can run.', 'thinkrank'), |
| 114 |
'<code>' . esc_url((string) ($report['scanned_url'] ?? '')) . '</code>', |
| 115 |
esc_html((string) $report['error']) |
| 116 |
) . '</p>' |
| 117 |
); |
| 118 |
} |
| 119 |
|
| 120 |
if (empty($report['conflicts'])) { |
| 121 |
return $this->result( |
| 122 |
'good', |
| 123 |
__('ThinkRank is the only source of schema markup on this page', 'thinkrank'), |
| 124 |
'<p>' . sprintf( |
| 125 |
/* translators: %s: scanned URL. */ |
| 126 |
esc_html__('ThinkRank checked %s and found no page-level schema markup from another plugin competing with its own.', 'thinkrank'), |
| 127 |
'<code>' . esc_url((string) ($report['scanned_url'] ?? '')) . '</code>' |
| 128 |
) . '</p>' |
| 129 |
); |
| 130 |
} |
| 131 |
|
| 132 |
return $this->result( |
| 133 |
'recommended', |
| 134 |
__('Another plugin is publishing schema markup on the same pages', 'thinkrank'), |
| 135 |
$this->conflict_description($report) |
| 136 |
); |
| 137 |
} |
| 138 |
|
| 139 |
/** |
| 140 |
* Body copy for a page carrying foreign schema. |
| 141 |
* |
| 142 |
* @param array<string, mixed> $report Scan report. |
| 143 |
* @return string |
| 144 |
*/ |
| 145 |
private function conflict_description(array $report): string { |
| 146 |
$html = '<p>' . sprintf( |
| 147 |
/* translators: %s: scanned URL. */ |
| 148 |
esc_html__('ThinkRank checked %s and found schema markup published by another plugin for the same page. Search engines see both, and nothing decides which one is right, so they may show the wrong title, image or breadcrumb trail, or ignore the markup altogether.', 'thinkrank'), |
| 149 |
'<code>' . esc_url((string) ($report['scanned_url'] ?? '')) . '</code>' |
| 150 |
) . '</p>'; |
| 151 |
|
| 152 |
$html .= '<ul>'; |
| 153 |
|
| 154 |
foreach ((array) $report['conflicts'] as $conflict) { |
| 155 |
$types = implode(', ', array_map('esc_html', (array) $conflict['duplicated'])); |
| 156 |
|
| 157 |
$line = empty($conflict['guess']) |
| 158 |
/* translators: 1: plugin name, 2: comma-separated schema types. */ |
| 159 |
? esc_html__('%1$s also publishes: %2$s', 'thinkrank') |
| 160 |
/* translators: 1: plugin name, 2: comma-separated schema types. */ |
| 161 |
: esc_html__('Probably %1$s (the block carries no plugin marker) also publishes: %2$s', 'thinkrank'); |
| 162 |
|
| 163 |
$html .= '<li>' . sprintf( |
| 164 |
$line, |
| 165 |
'<strong>' . esc_html((string) $conflict['name']) . '</strong>', |
| 166 |
'<code>' . $types . '</code>' |
| 167 |
) . '</li>'; |
| 168 |
} |
| 169 |
|
| 170 |
$html .= '</ul>'; |
| 171 |
|
| 172 |
$html .= '<p>' . esc_html__('ThinkRank does not change or remove another plugin\'s markup. To resolve this, keep one source of schema markup: either turn off the other plugin\'s schema output, or turn off ThinkRank\'s under SEO Settings.', 'thinkrank') . '</p>'; |
| 173 |
|
| 174 |
return $html; |
| 175 |
} |
| 176 |
|
| 177 |
/** |
| 178 |
* Assemble a Site Health result array. |
| 179 |
* |
| 180 |
* @param string $status One of good, recommended, critical. |
| 181 |
* @param string $label Result headline. |
| 182 |
* @param string $description Result body HTML. |
| 183 |
* @return array<string, mixed> |
| 184 |
*/ |
| 185 |
private function result(string $status, string $label, string $description): array { |
| 186 |
return [ |
| 187 |
'label' => $label, |
| 188 |
'status' => $status, |
| 189 |
'badge' => [ |
| 190 |
'label' => __('SEO', 'thinkrank'), |
| 191 |
'color' => 'blue', |
| 192 |
], |
| 193 |
'description' => $description, |
| 194 |
'actions' => '', |
| 195 |
'test' => self::TEST, |
| 196 |
]; |
| 197 |
} |
| 198 |
} |
| 199 |
|