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.10.0 2.9.0 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 All 51 releases
thinkrank / includes / diagnostics / class-schema-conflict-health-check.php

class-schema-conflict-health-check.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 2.9.0, at includes/diagnostics/class-schema-conflict-health-check.php

199 lines 7.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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