PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.4.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.4.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 1.1.0 1.10.0 All 48 releases
thinkrank / includes / seo / class-seo-analyzer.php

class-seo-analyzer.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 2.4.0, at includes/seo/class-seo-analyzer.php

979 lines 41.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Site SEO Analyzer
4 *
5 * Runs a crawl-free, site-wide SEO audit: a registry of individual checks is
6 * evaluated against the site's own configuration and a bounded sample of its
7 * published content, then aggregated into one overall 0–100 score, a letter
8 * grade, and per-category subtotals. Unlike the analytics-based "SEO health
9 * score", this requires no Google connection — it works out of the box.
10 *
11 * The result is cached in a transient; callers force a fresh run to bust it.
12 * Checks are registered through the `thinkrank_seo_analyzer_checks` filter so
13 * Pro/add-ons can contribute more without touching this class.
14 *
15 * @package ThinkRank\SEO
16 * @since 1.18.0
17 */
18
19 declare(strict_types=1);
20
21 namespace ThinkRank\SEO;
22
23 // Prevent direct access
24 if (!defined('ABSPATH')) {
25 exit;
26 }
27
28 /**
29 * SEO Analyzer Class
30 *
31 * @since 1.18.0
32 */
33 class SEO_Analyzer {
34
35 /**
36 * Transient key holding the last full analysis.
37 */
38 private const CACHE_KEY = 'thinkrank_site_seo_analysis';
39
40 /**
41 * How long a computed analysis stays cached (seconds).
42 */
43 private const CACHE_TTL = HOUR_IN_SECONDS;
44
45 // Check result statuses.
46 public const PASSED = 'passed';
47 public const WARNING = 'warning';
48 public const FAILED = 'failed';
49
50 /**
51 * Human-readable labels for each category id.
52 *
53 * @return array<string,string>
54 */
55 private function get_category_labels(): array {
56 return [
57 'basic' => __('Basic SEO', 'thinkrank'),
58 'advanced' => __('Advanced SEO', 'thinkrank'),
59 'content' => __('Content', 'thinkrank'),
60 'performance' => __('Performance & Technical', 'thinkrank'),
61 'security' => __('Security', 'thinkrank'),
62 ];
63 }
64
65 /**
66 * WordPress options whose value the analyzer reports on directly.
67 *
68 * @since 2.2.0
69 * @var string[]
70 */
71 private const WATCHED_OPTIONS = [
72 'blog_public',
73 'permalink_structure',
74 'blogname',
75 'blogdescription',
76 ];
77
78 /**
79 * Register cache invalidation.
80 *
81 * The analysis is cached for an hour, and until now only the image alt-text
82 * bulk writer ever busted it — so changing any other setting the audit
83 * reports on left the screen confidently wrong for up to 60 minutes. The
84 * audit's whole job is to describe the site's current configuration, so it
85 * invalidates on every write it could possibly be reading.
86 *
87 * @since 2.2.0
88 * @return void
89 */
90 public function init(): void {
91 foreach (self::WATCHED_OPTIONS as $option) {
92 add_action("update_option_{$option}", [$this, 'flush_cache']);
93 add_action("add_option_{$option}", [$this, 'flush_cache']);
94 }
95
96 // Any ThinkRank settings category can feed a check (sitemap, schema,
97 // image SEO today; more later). Flushing on all of them is cheaper than
98 // a list that silently rots as checks are added.
99 add_action('thinkrank_seo_settings_saved', [$this, 'flush_cache']);
100 }
101
102 /**
103 * Return the cached analysis, computing (and caching) it when missing or
104 * when a fresh run is forced.
105 *
106 * @param bool $force When true, ignore and overwrite the cached result.
107 * @return array The analysis payload (see analyze()).
108 */
109 public function run(bool $force = false): array {
110 if (!$force) {
111 $cached = get_transient(self::CACHE_KEY);
112 if (is_array($cached) && isset($cached['overall_score'])) {
113 return $cached;
114 }
115 }
116
117 $result = $this->analyze();
118 set_transient(self::CACHE_KEY, $result, self::CACHE_TTL);
119
120 return $result;
121 }
122
123 /**
124 * Clear the cached analysis so the next run() recomputes.
125 *
126 * @return void
127 */
128 public function flush_cache(): void {
129 delete_transient(self::CACHE_KEY);
130 }
131
132 /**
133 * Run every registered check and aggregate the results.
134 *
135 * @return array {
136 * @type int $overall_score Weighted 0–100 site score.
137 * @type string $grade Letter grade A–F.
138 * @type array $summary passed/warning/failed/total counts.
139 * @type array $categories Per-category subtotal + its checks.
140 * @type array $checks Flat list of every check result.
141 * @type string $generated_at ISO-8601 UTC timestamp.
142 * }
143 */
144 public function analyze(): array {
145 $checks = $this->run_checks();
146 $category_labels = $this->get_category_labels();
147
148 $fraction = [
149 self::PASSED => 1.0,
150 self::WARNING => 0.5,
151 self::FAILED => 0.0,
152 ];
153
154 $total_weight = 0.0;
155 $earned = 0.0;
156 $summary = [self::PASSED => 0, self::WARNING => 0, self::FAILED => 0, 'total' => 0];
157 $categories = [];
158
159 foreach ($checks as $check) {
160 $weight = (float) $check['weight'];
161 $status = $check['status'];
162 $frac = $fraction[$status] ?? 0.0;
163
164 $total_weight += $weight;
165 $earned += $weight * $frac;
166
167 $summary[$status] = ($summary[$status] ?? 0) + 1;
168 $summary['total']++;
169
170 $cat = $check['category'];
171 if (!isset($categories[$cat])) {
172 $categories[$cat] = [
173 'id' => $cat,
174 'label' => $category_labels[$cat] ?? ucfirst($cat),
175 'score' => 0,
176 'weight' => 0.0,
177 'earned' => 0.0,
178 self::PASSED => 0,
179 self::WARNING => 0,
180 self::FAILED => 0,
181 'checks' => [],
182 ];
183 }
184 $categories[$cat]['weight'] += $weight;
185 $categories[$cat]['earned'] += $weight * $frac;
186 $categories[$cat][$status] = ($categories[$cat][$status] ?? 0) + 1;
187 $categories[$cat]['checks'][] = $check;
188 }
189
190 // Finalize per-category scores and drop the internal accumulators.
191 foreach ($categories as $cat => &$data) {
192 $data['score'] = $data['weight'] > 0
193 ? (int) round(($data['earned'] / $data['weight']) * 100)
194 : 0;
195 unset($data['weight'], $data['earned']);
196 }
197 unset($data);
198
199 $overall = $total_weight > 0 ? (int) round(($earned / $total_weight) * 100) : 0;
200
201 return [
202 'overall_score' => $overall,
203 'grade' => $this->score_to_grade($overall),
204 'summary' => $summary,
205 'categories' => array_values($categories),
206 'checks' => $checks,
207 'generated_at' => gmdate('c'),
208 ];
209 }
210
211 /**
212 * Map a 0–100 score to a letter grade.
213 *
214 * @param int $score The overall score.
215 * @return string Letter grade.
216 */
217 private function score_to_grade(int $score): string {
218 if ($score >= 90) {
219 return 'A';
220 }
221 if ($score >= 80) {
222 return 'B';
223 }
224 if ($score >= 70) {
225 return 'C';
226 }
227 if ($score >= 60) {
228 return 'D';
229 }
230 return 'F';
231 }
232
233 /**
234 * Evaluate every registered check, normalizing each result.
235 *
236 * A check whose callback throws or returns a malformed value is skipped so
237 * one broken check can't take down the whole analysis.
238 *
239 * @return array<int,array> Normalized check results.
240 */
241 private function run_checks(): array {
242 $results = [];
243
244 foreach ($this->get_check_definitions() as $def) {
245 if (empty($def['callback']) || !is_callable($def['callback'])) {
246 continue;
247 }
248
249 try {
250 $outcome = call_user_func($def['callback']);
251 } catch (\Throwable $e) {
252 continue;
253 }
254
255 if (!is_array($outcome) || empty($outcome['status'])) {
256 continue;
257 }
258
259 $id = (string) ($def['id'] ?? '');
260 $status = (string) $outcome['status'];
261
262 // Only offer a fix on a finding that still needs one — a passing
263 // check with a Fix button reads as "did this even work?".
264 $fixable = self::PASSED !== $status && SEO_Analyzer_Fixer::can_fix($id);
265 $fix = $fixable ? (SEO_Analyzer_Fixer::fixable()[$id] ?? []) : [];
266
267 $results[] = [
268 'id' => $id,
269 'category' => (string) ($def['category'] ?? 'basic'),
270 'weight' => isset($def['weight']) ? (float) $def['weight'] : 1.0,
271 'label' => (string) ($outcome['label'] ?? $def['label'] ?? ''),
272 'status' => $status,
273 'message' => (string) ($outcome['message'] ?? ''),
274 'how_to_fix' => (string) ($outcome['how_to_fix'] ?? ''),
275 'value' => $outcome['value'] ?? null,
276 'can_auto_fix' => $fixable,
277 'fix_label' => (string) ($fix['label'] ?? ''),
278 'fix_warning' => (string) ($fix['warning'] ?? ''),
279 ];
280 }
281
282 return $results;
283 }
284
285 /**
286 * The registry of checks: id, category, weight, and the callback that
287 * evaluates it. Filterable so Pro/add-ons can register additional checks.
288 *
289 * @return array<int,array>
290 */
291 private function get_check_definitions(): array {
292 $definitions = [
293 // Basic SEO
294 ['id' => 'site_title', 'category' => 'basic', 'weight' => 2, 'callback' => [$this, 'check_site_title']],
295 ['id' => 'tagline', 'category' => 'basic', 'weight' => 1, 'callback' => [$this, 'check_tagline']],
296 ['id' => 'search_visibility', 'category' => 'basic', 'weight' => 3, 'callback' => [$this, 'check_search_visibility']],
297 ['id' => 'permalinks', 'category' => 'basic', 'weight' => 2, 'callback' => [$this, 'check_permalinks']],
298
299 // Advanced SEO
300 ['id' => 'xml_sitemap', 'category' => 'advanced', 'weight' => 2, 'callback' => [$this, 'check_sitemap']],
301 ['id' => 'schema', 'category' => 'advanced', 'weight' => 2, 'callback' => [$this, 'check_schema']],
302
303 // Content (bounded sample of published content)
304 ['id' => 'meta_descriptions', 'category' => 'content', 'weight' => 2, 'callback' => [$this, 'check_meta_descriptions']],
305 ['id' => 'image_alt_text', 'category' => 'content', 'weight' => 2, 'callback' => [$this, 'check_image_alt_text']],
306
307 // Performance & Technical
308 ['id' => 'php_version', 'category' => 'performance', 'weight' => 1, 'callback' => [$this, 'check_php_version']],
309 ['id' => 'object_cache', 'category' => 'performance', 'weight' => 1, 'callback' => [$this, 'check_object_cache']],
310
311 // Security
312 ['id' => 'https', 'category' => 'security', 'weight' => 3, 'callback' => [$this, 'check_https']],
313 ['id' => 'file_editing', 'category' => 'security', 'weight' => 2, 'callback' => [$this, 'check_file_editing']],
314 ['id' => 'debug_display', 'category' => 'security', 'weight' => 1, 'callback' => [$this, 'check_debug_display']],
315 ];
316
317 /**
318 * Filter the Site SEO Analyzer check registry.
319 *
320 * Each entry is an array with keys: id, category (basic|advanced|
321 * content|performance|security), weight (float), and callback (callable
322 * returning ['status' => passed|warning|failed, 'label', 'message',
323 * 'how_to_fix']).
324 *
325 * @since 1.18.0
326 *
327 * @param array $definitions Registered checks.
328 * @param SEO_Analyzer $analyzer The analyzer instance.
329 */
330 $definitions = apply_filters('thinkrank_seo_analyzer_checks', $definitions, $this);
331
332 return is_array($definitions) ? $definitions : [];
333 }
334
335 // ─────────────────────────────────────────────────────────────────────
336 // Basic SEO checks
337 // ─────────────────────────────────────────────────────────────────────
338
339 /**
340 * The site must have a name/title configured.
341 *
342 * @return array
343 */
344 public function check_site_title(): array {
345 $title = trim((string) get_bloginfo('name'));
346
347 if ($title === '') {
348 return [
349 'label' => __('Site title is set', 'thinkrank'),
350 'status' => self::FAILED,
351 'message' => __('Your site has no title. Search engines and browsers use it as your brand name.', 'thinkrank'),
352 'how_to_fix' => __('Set a site title under Settings → General → Site Title.', 'thinkrank'),
353 ];
354 }
355
356 return [
357 'label' => __('Site title is set', 'thinkrank'),
358 'status' => self::PASSED,
359 'message' => __('Your site title is configured.', 'thinkrank'),
360 'value' => $title,
361 ];
362 }
363
364 /**
365 * The tagline should be set and not left at the WordPress default.
366 *
367 * @return array
368 */
369 public function check_tagline(): array {
370 $tagline = trim((string) get_bloginfo('description'));
371
372 $is_default = $this->is_default_tagline($tagline);
373
374 if ($tagline === '' || $is_default) {
375 return [
376 'label' => __('Tagline is customized', 'thinkrank'),
377 'status' => self::WARNING,
378 'message' => __('Your tagline is blank or still the WordPress default. Search engines may use it as your homepage description.', 'thinkrank'),
379 'how_to_fix' => __('Write a descriptive tagline under Settings → General → Tagline.', 'thinkrank'),
380 ];
381 }
382
383 return [
384 'label' => __('Tagline is customized', 'thinkrank'),
385 'status' => self::PASSED,
386 'message' => __('Your tagline is set and ready to describe your site.', 'thinkrank'),
387 'value' => $tagline,
388 ];
389 }
390
391 /**
392 * Whether a tagline is still WordPress' shipped default.
393 *
394 * The installer writes the TRANSLATED default into blogdescription, so an
395 * English-only literal silently passed an untouched tagline on every
396 * non-English install. The string lives in core's `admin-{locale}.mo`,
397 * which a REST request (how this analyzer runs) does not load — so the
398 * catalogue is loaded on demand for the comparison when the site is not
399 * running in English.
400 *
401 * @since 2.2.0
402 * @param string $tagline Trimmed tagline.
403 * @return bool
404 */
405 private function is_default_tagline(string $tagline): bool {
406 $candidates = ['Just another WordPress site'];
407
408 $locale = get_locale();
409 if ('en_US' !== $locale) {
410 // phpcs:ignore WordPress.WP.I18n.TextDomainMismatch,WordPress.WP.I18n.LowLevelTranslationFunction -- core's own string in the `default` domain, read at runtime.
411 $translated = translate('Just another WordPress site', 'default');
412
413 if ($translated === 'Just another WordPress site') {
414 // Not in the loaded catalogue — pull in the admin one, which is
415 // where core ships this string, then ask again.
416 $mofile = WP_LANG_DIR . '/admin-' . $locale . '.mo';
417 if (is_readable($mofile)) {
418 load_textdomain('default', $mofile, $locale);
419 // phpcs:ignore WordPress.WP.I18n.TextDomainMismatch,WordPress.WP.I18n.LowLevelTranslationFunction -- as above.
420 $translated = translate('Just another WordPress site', 'default');
421 }
422 }
423
424 $candidates[] = $translated;
425 }
426
427 foreach ($candidates as $candidate) {
428 if (strtolower($tagline) === strtolower($candidate)) {
429 return true;
430 }
431 }
432
433 return false;
434 }
435
436 /**
437 * "Discourage search engines from indexing this site" must be OFF.
438 *
439 * @return array
440 */
441 public function check_search_visibility(): array {
442 // blog_public = 0 means the WP "Discourage search engines" box is ticked.
443 if (!get_option('blog_public')) {
444 return [
445 'label' => __('Site is visible to search engines', 'thinkrank'),
446 'status' => self::FAILED,
447 'message' => __('Your site is telling search engines not to index it — it will not appear in search results.', 'thinkrank'),
448 'how_to_fix' => __('Untick "Discourage search engines from indexing this site" under Settings → Reading.', 'thinkrank'),
449 ];
450 }
451
452 return [
453 'label' => __('Site is visible to search engines', 'thinkrank'),
454 'status' => self::PASSED,
455 'message' => __('Your site allows search engines to index it.', 'thinkrank'),
456 ];
457 }
458
459 /**
460 * Permalinks should be pretty (not the default plain ?p=123 structure).
461 *
462 * @return array
463 */
464 public function check_permalinks(): array {
465 $structure = (string) get_option('permalink_structure');
466
467 if ($structure === '') {
468 return [
469 'label' => __('Search-friendly permalinks', 'thinkrank'),
470 'status' => self::WARNING,
471 'message' => __('Your site uses plain, numeric URLs (e.g. ?p=123). Descriptive URLs are easier for search engines and users.', 'thinkrank'),
472 'how_to_fix' => __('Choose a pretty permalink structure (e.g. Post name) under Settings → Permalinks.', 'thinkrank'),
473 ];
474 }
475
476 return [
477 'label' => __('Search-friendly permalinks', 'thinkrank'),
478 'status' => self::PASSED,
479 'message' => __('Your permalinks are search-friendly.', 'thinkrank'),
480 'value' => $structure,
481 ];
482 }
483
484 // ─────────────────────────────────────────────────────────────────────
485 // Advanced SEO checks
486 // ─────────────────────────────────────────────────────────────────────
487
488 /**
489 * The ThinkRank XML sitemap should be enabled.
490 *
491 * @return array
492 */
493 public function check_sitemap(): array {
494 $enabled = true;
495 try {
496 $generator = new Sitemap_Generator();
497 // 'site' is the stored context; 'global' is unsupported and
498 // returns DEFAULTS (enabled=true), which made this check unable
499 // to fail no matter what the user configured.
500 $data = $generator->get_output_data('site', null);
501 $enabled = !empty($data['enabled']);
502 } catch (\Throwable $e) {
503 // Fall back to "enabled" — the default state — on any lookup error.
504 $enabled = true;
505 }
506
507 if (!$enabled) {
508 return [
509 'label' => __('XML sitemap is enabled', 'thinkrank'),
510 'status' => self::WARNING,
511 'message' => __('Your XML sitemap is turned off. Search engines rely on it to discover new pages quickly.', 'thinkrank'),
512 'how_to_fix' => __('Enable the XML sitemap under Essential SEO → Crawling & AI Indexing → XML Sitemap.', 'thinkrank'),
513 ];
514 }
515
516 return [
517 'label' => __('XML sitemap is enabled', 'thinkrank'),
518 'status' => self::PASSED,
519 'message' => __('Your XML sitemap is enabled and pointing crawlers to your content.', 'thinkrank'),
520 ];
521 }
522
523 /**
524 * Structured data (schema) should be configured for at least one post type.
525 *
526 * @return array
527 */
528 public function check_schema(): array {
529 $label = __('Structured data configured', 'thinkrank');
530
531 if ($this->schema_is_configured()) {
532 return [
533 'label' => $label,
534 'status' => self::PASSED,
535 'message' => __('Structured data is configured for your content.', 'thinkrank'),
536 ];
537 }
538
539 // Nothing is configured, but ThinkRank still emits JSON-LD from its
540 // built-in per-post-type defaults. Saying "no schema" there would be
541 // false; the actionable point is that nobody has reviewed it.
542 if ($this->schema_is_output()) {
543 return [
544 'label' => $label,
545 'status' => self::WARNING,
546 'message' => __('Structured data is running on ThinkRank\'s built-in defaults. Reviewing the schema type for each post type gives you control over how rich results appear.', 'thinkrank'),
547 'how_to_fix' => __('Choose a schema type for each post type under Essential SEO → Bulk SEO Optimization.', 'thinkrank'),
548 ];
549 }
550
551 return [
552 'label' => $label,
553 'status' => self::FAILED,
554 'message' => __('No schema/structured data is configured or emitted. Schema powers rich results in search.', 'thinkrank'),
555 'how_to_fix' => __('Turn on automatic structured data, or choose a schema type for each post type under Essential SEO → Bulk SEO Optimization.', 'thinkrank'),
556 ];
557 }
558
559 /**
560 * Whether the user has EXPLICITLY configured structured data.
561 *
562 * Distinct from schema_is_output(): the Global SEO layer falls back to a
563 * built-in schema type for every public post type, so "something is
564 * emitted" is true on every site and made this check impossible to fail
565 * (its weight was earned unconditionally and its one-click fix was
566 * unreachable). This asks the question the check's copy actually claims to
567 * answer.
568 *
569 * Both layers must be read WITHOUT their defaults, or the same trap closes
570 * again one level down: get_settings() merges the context defaults under
571 * the saved rows, and Schema_Settings_Config's 'site' defaults set both
572 * enabled_schema_types and auto_generate_schema — so an untouched site came
573 * back looking configured and this method still could not return false
574 * (#586). get_stored_settings() answers with only what was actually saved.
575 *
576 * @since 2.2.0
577 * @return bool
578 */
579 private function schema_is_configured(): bool {
580 // 1) Schema Management System — an explicit opt-in. Read the SAVED rows
581 // only; the defaults-merged view is truthy on every site.
582 if (class_exists('ThinkRank\\SEO\\Schema_Management_System')) {
583 $settings = (new Schema_Management_System())->get_stored_settings('site', null);
584 // The master switch gates these the same way it gates
585 // schema_is_output(). Saving the settings form persists the whole
586 // payload, so turning the feature off stores enabled = '0' while
587 // auto_generate_schema stays '1' — and reading past the switch then
588 // reported "structured data is configured" for a site emitting
589 // none, with the one-click fix withheld. array_key_exists rather
590 // than a bare !empty so an untouched site, where 'enabled' was
591 // never saved at all, still falls through to the Global SEO layer
592 // below instead of short-circuiting to false.
593 $master_on = is_array($settings)
594 && (!array_key_exists('enabled', $settings) || !empty($settings['enabled']));
595
596 if ($master_on) {
597 if (!empty($settings['enabled_schema_types']) && is_array($settings['enabled_schema_types'])) {
598 return true;
599 }
600 if (!empty($settings['auto_generate_schema'])) {
601 return true;
602 }
603 }
604 }
605
606 // 2) A saved per-post-type schema_type in the Global SEO layer. The
607 // built-in default deliberately does not count here.
608 if (class_exists('ThinkRank\\Frontend\\Global_SEO_Schema_Output')) {
609 $output = new \ThinkRank\Frontend\Global_SEO_Schema_Output();
610 foreach (get_post_types(['public' => true], 'names') as $post_type) {
611 if ($output->has_explicit_schema_type((string) $post_type)) {
612 return true;
613 }
614 }
615 }
616
617 return false;
618 }
619
620 /**
621 * Whether ThinkRank actually emits structured data for this site.
622 *
623 * The audit must reflect what is rendered, not a single legacy option.
624 * ThinkRank outputs schema from two current sources, so this check consults
625 * both rather than the deprecated thinkrank_global_seo_settings['schema_type']
626 * opt-in (which most sites never set even though schema is emitted):
627 *
628 * 1. The Schema Management System — its configuration lives in the
629 * thinkrank_seo_settings table (context "schema_management_system"),
630 * read through the manager's settings abstraction.
631 * 2. The Global SEO output layer — an explicit saved schema_type OR the
632 * built-in per-post-type default both cause JSON-LD to be emitted on
633 * the frontend. We ask that layer directly (would_output_schema) so the
634 * audit and the rendered page can never diverge.
635 *
636 * @return bool True when structured data is emitted for the site's content.
637 */
638 private function schema_is_output(): bool {
639 // 1) Newer Schema Management System (thinkrank_seo_settings table).
640 if (class_exists('ThinkRank\\SEO\\Schema_Management_System')) {
641 // 'site' is the context type; 'schema_management_system' is the manager
642 // NAME, which get_settings() rejects as an unsupported context and
643 // answers with bare defaults — where auto_generate_schema is true, so
644 // this always returned true and never read the site's real settings (#473).
645 //
646 // This one KEEPS the defaults-merged view on purpose, unlike
647 // schema_is_configured() (#586). The question here is "does JSON-LD
648 // reach the page?", and an untouched site answers yes: the 'site'
649 // defaults leave the system enabled with auto_generate_schema on, so
650 // the merged value is the emitted behaviour, not a mask over it.
651 $settings = (new Schema_Management_System())->get_settings('site', null);
652 // The master switch gates everything below it: with 'enabled' off,
653 // get_output_data() reports the feature as off and nothing is
654 // emitted, so reading auto_generate_schema past it told the audit
655 // schema was on the page when it was not (the same shape as #461).
656 if (is_array($settings) && !empty($settings['enabled'])) {
657 if (!empty($settings['enabled_schema_types']) && is_array($settings['enabled_schema_types'])) {
658 return true;
659 }
660 if (!empty($settings['auto_generate_schema'])) {
661 return true;
662 }
663 }
664 }
665
666 // 2) Global SEO output layer — explicit schema_type or per-post-type
667 // default. Reuse the output layer's own decision so audit == output.
668 if (class_exists('ThinkRank\\Frontend\\Global_SEO_Schema_Output')) {
669 $output = new \ThinkRank\Frontend\Global_SEO_Schema_Output();
670 foreach (get_post_types(['public' => true], 'names') as $post_type) {
671 if ($output->would_output_schema((string) $post_type)) {
672 return true;
673 }
674 }
675 }
676
677 return false;
678 }
679
680 // ─────────────────────────────────────────────────────────────────────
681 // Content checks (bounded sample of published content)
682 // ─────────────────────────────────────────────────────────────────────
683
684 /**
685 * How many recent published posts/pages the content checks sample.
686 */
687 private const CONTENT_SAMPLE_SIZE = 100;
688
689 /**
690 * Coverage thresholds shared by the content checks: at or above the first
691 * is a pass, at or above the second is a warning, below it a fail.
692 */
693 private const COVERAGE_PASS = 90;
694 private const COVERAGE_WARN = 50;
695
696 /**
697 * Recent published posts/pages should have meta descriptions.
698 *
699 * Samples the most recent CONTENT_SAMPLE_SIZE published posts/pages so the
700 * check stays fast on large sites.
701 *
702 * @return array
703 */
704 public function check_meta_descriptions(): array {
705 $label = __('Posts have meta descriptions', 'thinkrank');
706
707 $post_ids = get_posts([
708 'post_type' => ['post', 'page'],
709 'post_status' => 'publish',
710 'posts_per_page' => self::CONTENT_SAMPLE_SIZE,
711 'orderby' => 'date',
712 'order' => 'DESC',
713 'fields' => 'ids',
714 'no_found_rows' => true,
715 'suppress_filters' => false,
716 ]);
717
718 $total = count($post_ids);
719 if (0 === $total) {
720 return [
721 'label' => $label,
722 'status' => self::PASSED,
723 'message' => __('No published content to check yet.', 'thinkrank'),
724 ];
725 }
726
727 // Count posts with an *effective* meta description, the same way the
728 // frontend resolves it: a custom _thinkrank_meta_description when set,
729 // otherwise the global SEO pattern fallback (Pattern_Resolver). Counting
730 // only the custom post-meta produced false negatives — posts that output
731 // a valid description via the pattern fallback were wrongly reported as
732 // missing. The sample is bounded (CONTENT_SAMPLE_SIZE) so the per-post
733 // resolution stays cheap, and the whole analysis is cached for an hour.
734 // 'fields' => 'ids' skips WP_Query's meta priming, so the first
735 // get_post_meta() below would issue a query per post. Warm the whole
736 // sample once instead — 100 posts went from ~200 queries to a handful.
737 _prime_post_caches($post_ids, false, true);
738
739 $with_description = 0;
740 foreach ($post_ids as $post_id) {
741 $custom = (string) get_post_meta($post_id, '_thinkrank_meta_description', true);
742 $resolved = '' !== $custom ? $custom : Pattern_Resolver::description((int) $post_id);
743 if ('' !== trim($resolved)) {
744 $with_description++;
745 }
746 }
747
748 $coverage = (int) round(($with_description / $total) * 100);
749 $missing = $total - $with_description;
750 $value = sprintf('%d/%d', $with_description, $total);
751
752 if ($coverage >= self::COVERAGE_PASS) {
753 return [
754 'label' => $label,
755 'status' => self::PASSED,
756 /* translators: 1: posts with meta description, 2: sampled posts. */
757 'message' => sprintf(__('%1$d of your %2$d most recent posts have a meta description.', 'thinkrank'), $with_description, $total),
758 'value' => $value,
759 ];
760 }
761
762 return [
763 'label' => $label,
764 'status' => $coverage >= self::COVERAGE_WARN ? self::WARNING : self::FAILED,
765 /* translators: 1: posts missing a meta description, 2: sampled posts. */
766 'message' => sprintf(__('%1$d of your %2$d most recent posts are missing a meta description. Search engines fall back to arbitrary page text for their snippets.', 'thinkrank'), $missing, $total),
767 'how_to_fix' => __('Add meta descriptions in the ThinkRank SEO panel when editing a post — or use Bulk SEO Optimization to generate them with AI.', 'thinkrank'),
768 'value' => $value,
769 ];
770 }
771
772 /**
773 * Uploaded images should have alt text — it is an accessibility
774 * requirement and how image search understands your media.
775 *
776 * @return array
777 */
778 public function check_image_alt_text(): array {
779 $label = __('Images have alt text', 'thinkrank');
780
781 global $wpdb;
782 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- two indexed COUNTs; results are cached at the analysis level
783 $total = (int) $wpdb->get_var(
784 "SELECT COUNT(*) FROM {$wpdb->posts}
785 WHERE post_type = 'attachment'
786 AND post_mime_type LIKE 'image/%'
787 AND post_status != 'trash'"
788 );
789
790 if (0 === $total) {
791 return [
792 'label' => $label,
793 'status' => self::PASSED,
794 'message' => __('No images in your media library to check yet.', 'thinkrank'),
795 ];
796 }
797
798 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- indexed COUNT via postmeta meta_key index
799 $with_alt = (int) $wpdb->get_var(
800 "SELECT COUNT(DISTINCT p.ID) FROM {$wpdb->posts} p
801 INNER JOIN {$wpdb->postmeta} pm
802 ON pm.post_id = p.ID
803 AND pm.meta_key = '_wp_attachment_image_alt'
804 AND pm.meta_value != ''
805 WHERE p.post_type = 'attachment'
806 AND p.post_mime_type LIKE 'image/%'
807 AND p.post_status != 'trash'"
808 );
809
810 $coverage = (int) round(($with_alt / $total) * 100);
811 $missing = $total - $with_alt;
812 $value = sprintf('%d/%d', $with_alt, $total);
813
814 if ($coverage >= self::COVERAGE_PASS) {
815 return [
816 'label' => $label,
817 'status' => self::PASSED,
818 /* translators: 1: images with alt text, 2: total images. */
819 'message' => sprintf(__('%1$d of your %2$d images have alt text.', 'thinkrank'), $with_alt, $total),
820 'value' => $value,
821 ];
822 }
823
824 return [
825 'label' => $label,
826 'status' => $coverage >= self::COVERAGE_WARN ? self::WARNING : self::FAILED,
827 /* translators: 1: images missing alt text, 2: total images. */
828 'message' => sprintf(__('%1$d of your %2$d images are missing alt text. Alt text drives image search rankings and is an accessibility requirement.', 'thinkrank'), $missing, $total),
829 'how_to_fix' => __('Under Essential SEO → Image SEO, turn on "Save alt text to the Media Library" and run "Fill missing alt text" to populate them from your format, or add alt text manually in the Media Library.', 'thinkrank'),
830 'value' => $value,
831 ];
832 }
833
834 // ─────────────────────────────────────────────────────────────────────
835 // Performance & Technical checks
836 // ─────────────────────────────────────────────────────────────────────
837
838 /**
839 * The site should run a supported PHP version.
840 *
841 * @return array
842 */
843 public function check_php_version(): array {
844 $current = PHP_VERSION;
845 // ThinkRank itself requires PHP 8.0 to run, so anything below 8.1 (the
846 // oldest actively-supported branch) is the meaningful warning line —
847 // a 7.x threshold here could never fire.
848 $supported = version_compare($current, '8.1', '>=');
849
850 if (!$supported) {
851 return [
852 'label' => __('Supported PHP version', 'thinkrank'),
853 'status' => self::WARNING,
854 /* translators: %s: current PHP version. */
855 'message' => sprintf(__('You are running PHP %s, which no longer receives active support. Newer PHP is faster and more secure.', 'thinkrank'), $current),
856 'how_to_fix' => __('Ask your host to upgrade to PHP 8.1 or newer.', 'thinkrank'),
857 'value' => $current,
858 ];
859 }
860
861 return [
862 'label' => __('Supported PHP version', 'thinkrank'),
863 'status' => self::PASSED,
864 /* translators: %s: current PHP version. */
865 'message' => sprintf(__('You are running a supported PHP version (%s).', 'thinkrank'), $current),
866 'value' => $current,
867 ];
868 }
869
870 /**
871 * A persistent object cache should be active for a faster, less DB-bound
872 * site.
873 *
874 * @return array
875 */
876 public function check_object_cache(): array {
877 if (function_exists('wp_using_ext_object_cache') && wp_using_ext_object_cache()) {
878 return [
879 'label' => __('Persistent object cache', 'thinkrank'),
880 'status' => self::PASSED,
881 'message' => __('A persistent object cache is active, reducing database load.', 'thinkrank'),
882 ];
883 }
884
885 return [
886 'label' => __('Persistent object cache', 'thinkrank'),
887 'status' => self::WARNING,
888 'message' => __('No persistent object cache is active. On busier sites this means more database queries per request.', 'thinkrank'),
889 'how_to_fix' => __('Enable a persistent object cache (e.g. Redis or Memcached) via your host or a caching plugin.', 'thinkrank'),
890 ];
891 }
892
893 // ─────────────────────────────────────────────────────────────────────
894 // Security checks
895 // ─────────────────────────────────────────────────────────────────────
896
897 /**
898 * The site should be served over HTTPS (SSL).
899 *
900 * @return array
901 */
902 public function check_https(): array {
903 $home = (string) get_option('home');
904 $uses_https = strpos($home, 'https://') === 0;
905
906 // WP 5.7+ can tell us the site is fully HTTPS-capable.
907 if (function_exists('wp_is_using_https')) {
908 $uses_https = $uses_https && wp_is_using_https();
909 }
910
911 if (!$uses_https) {
912 return [
913 'label' => __('Site uses HTTPS (SSL)', 'thinkrank'),
914 'status' => self::FAILED,
915 'message' => __('Your site URL is not served over HTTPS. HTTPS is a confirmed ranking signal and required for user trust.', 'thinkrank'),
916 'how_to_fix' => __('Install an SSL certificate and set your WordPress Address / Site Address to https:// under Settings → General.', 'thinkrank'),
917 ];
918 }
919
920 return [
921 'label' => __('Site uses HTTPS (SSL)', 'thinkrank'),
922 'status' => self::PASSED,
923 'message' => __('Your site is served securely over HTTPS.', 'thinkrank'),
924 ];
925 }
926
927 /**
928 * The built-in plugin/theme file editor should be disabled
929 * (DISALLOW_FILE_EDIT) so a compromised admin cannot edit PHP from wp-admin.
930 *
931 * @return array
932 */
933 public function check_file_editing(): array {
934 if (defined('DISALLOW_FILE_EDIT') && DISALLOW_FILE_EDIT) {
935 return [
936 'label' => __('File editing disabled', 'thinkrank'),
937 'status' => self::PASSED,
938 'message' => __('The dashboard plugin/theme file editor is disabled, reducing your attack surface.', 'thinkrank'),
939 ];
940 }
941
942 return [
943 'label' => __('File editing disabled', 'thinkrank'),
944 'status' => self::WARNING,
945 'message' => __('The built-in file editor is enabled. If an admin account is compromised, an attacker could edit your PHP files from wp-admin.', 'thinkrank'),
946 'how_to_fix' => __('Add define(\'DISALLOW_FILE_EDIT\', true); to your wp-config.php.', 'thinkrank'),
947 ];
948 }
949
950 /**
951 * The site should not publicly display PHP errors (WP_DEBUG_DISPLAY),
952 * which can leak server paths and internals.
953 *
954 * @return array
955 */
956 public function check_debug_display(): array {
957 $debug = defined('WP_DEBUG') && WP_DEBUG;
958 // WP_DEBUG_DISPLAY only shows errors when it is on (its default) AND
959 // WP_DEBUG is enabled.
960 $display = !defined('WP_DEBUG_DISPLAY') || WP_DEBUG_DISPLAY;
961 $exposing = $debug && $display;
962
963 if ($exposing) {
964 return [
965 'label' => __('Errors not shown publicly', 'thinkrank'),
966 'status' => self::WARNING,
967 'message' => __('Debug output is displayed on the front end. Visible PHP errors can leak server paths and internals.', 'thinkrank'),
968 'how_to_fix' => __('Set define(\'WP_DEBUG_DISPLAY\', false); (or turn off WP_DEBUG) in wp-config.php on production.', 'thinkrank'),
969 ];
970 }
971
972 return [
973 'label' => __('Errors not shown publicly', 'thinkrank'),
974 'status' => self::PASSED,
975 'message' => __('PHP errors are not displayed to visitors.', 'thinkrank'),
976 ];
977 }
978 }
979