PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.0.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.0.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.0.0, at includes/seo/class-seo-analyzer.php

804 lines 33.2 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 * Return the cached analysis, computing (and caching) it when missing or
67 * when a fresh run is forced.
68 *
69 * @param bool $force When true, ignore and overwrite the cached result.
70 * @return array The analysis payload (see analyze()).
71 */
72 public function run(bool $force = false): array {
73 if (!$force) {
74 $cached = get_transient(self::CACHE_KEY);
75 if (is_array($cached) && isset($cached['overall_score'])) {
76 return $cached;
77 }
78 }
79
80 $result = $this->analyze();
81 set_transient(self::CACHE_KEY, $result, self::CACHE_TTL);
82
83 return $result;
84 }
85
86 /**
87 * Clear the cached analysis so the next run() recomputes.
88 *
89 * @return void
90 */
91 public function flush_cache(): void {
92 delete_transient(self::CACHE_KEY);
93 }
94
95 /**
96 * Run every registered check and aggregate the results.
97 *
98 * @return array {
99 * @type int $overall_score Weighted 0–100 site score.
100 * @type string $grade Letter grade A–F.
101 * @type array $summary passed/warning/failed/total counts.
102 * @type array $categories Per-category subtotal + its checks.
103 * @type array $checks Flat list of every check result.
104 * @type string $generated_at ISO-8601 UTC timestamp.
105 * }
106 */
107 public function analyze(): array {
108 $checks = $this->run_checks();
109 $category_labels = $this->get_category_labels();
110
111 $fraction = [
112 self::PASSED => 1.0,
113 self::WARNING => 0.5,
114 self::FAILED => 0.0,
115 ];
116
117 $total_weight = 0.0;
118 $earned = 0.0;
119 $summary = [self::PASSED => 0, self::WARNING => 0, self::FAILED => 0, 'total' => 0];
120 $categories = [];
121
122 foreach ($checks as $check) {
123 $weight = (float) $check['weight'];
124 $status = $check['status'];
125 $frac = $fraction[$status] ?? 0.0;
126
127 $total_weight += $weight;
128 $earned += $weight * $frac;
129
130 $summary[$status] = ($summary[$status] ?? 0) + 1;
131 $summary['total']++;
132
133 $cat = $check['category'];
134 if (!isset($categories[$cat])) {
135 $categories[$cat] = [
136 'id' => $cat,
137 'label' => $category_labels[$cat] ?? ucfirst($cat),
138 'score' => 0,
139 'weight' => 0.0,
140 'earned' => 0.0,
141 self::PASSED => 0,
142 self::WARNING => 0,
143 self::FAILED => 0,
144 'checks' => [],
145 ];
146 }
147 $categories[$cat]['weight'] += $weight;
148 $categories[$cat]['earned'] += $weight * $frac;
149 $categories[$cat][$status] = ($categories[$cat][$status] ?? 0) + 1;
150 $categories[$cat]['checks'][] = $check;
151 }
152
153 // Finalize per-category scores and drop the internal accumulators.
154 foreach ($categories as $cat => &$data) {
155 $data['score'] = $data['weight'] > 0
156 ? (int) round(($data['earned'] / $data['weight']) * 100)
157 : 0;
158 unset($data['weight'], $data['earned']);
159 }
160 unset($data);
161
162 $overall = $total_weight > 0 ? (int) round(($earned / $total_weight) * 100) : 0;
163
164 return [
165 'overall_score' => $overall,
166 'grade' => $this->score_to_grade($overall),
167 'summary' => $summary,
168 'categories' => array_values($categories),
169 'checks' => $checks,
170 'generated_at' => gmdate('c'),
171 ];
172 }
173
174 /**
175 * Map a 0–100 score to a letter grade.
176 *
177 * @param int $score The overall score.
178 * @return string Letter grade.
179 */
180 private function score_to_grade(int $score): string {
181 if ($score >= 90) {
182 return 'A';
183 }
184 if ($score >= 80) {
185 return 'B';
186 }
187 if ($score >= 70) {
188 return 'C';
189 }
190 if ($score >= 60) {
191 return 'D';
192 }
193 return 'F';
194 }
195
196 /**
197 * Evaluate every registered check, normalizing each result.
198 *
199 * A check whose callback throws or returns a malformed value is skipped so
200 * one broken check can't take down the whole analysis.
201 *
202 * @return array<int,array> Normalized check results.
203 */
204 private function run_checks(): array {
205 $results = [];
206
207 foreach ($this->get_check_definitions() as $def) {
208 if (empty($def['callback']) || !is_callable($def['callback'])) {
209 continue;
210 }
211
212 try {
213 $outcome = call_user_func($def['callback']);
214 } catch (\Throwable $e) {
215 continue;
216 }
217
218 if (!is_array($outcome) || empty($outcome['status'])) {
219 continue;
220 }
221
222 $id = (string) ($def['id'] ?? '');
223 $status = (string) $outcome['status'];
224
225 // Only offer a fix on a finding that still needs one — a passing
226 // check with a Fix button reads as "did this even work?".
227 $fixable = self::PASSED !== $status && SEO_Analyzer_Fixer::can_fix($id);
228 $fix = $fixable ? (SEO_Analyzer_Fixer::fixable()[$id] ?? []) : [];
229
230 $results[] = [
231 'id' => $id,
232 'category' => (string) ($def['category'] ?? 'basic'),
233 'weight' => isset($def['weight']) ? (float) $def['weight'] : 1.0,
234 'label' => (string) ($outcome['label'] ?? $def['label'] ?? ''),
235 'status' => $status,
236 'message' => (string) ($outcome['message'] ?? ''),
237 'how_to_fix' => (string) ($outcome['how_to_fix'] ?? ''),
238 'value' => $outcome['value'] ?? null,
239 'can_auto_fix' => $fixable,
240 'fix_label' => (string) ($fix['label'] ?? ''),
241 'fix_warning' => (string) ($fix['warning'] ?? ''),
242 ];
243 }
244
245 return $results;
246 }
247
248 /**
249 * The registry of checks: id, category, weight, and the callback that
250 * evaluates it. Filterable so Pro/add-ons can register additional checks.
251 *
252 * @return array<int,array>
253 */
254 private function get_check_definitions(): array {
255 $definitions = [
256 // Basic SEO
257 ['id' => 'site_title', 'category' => 'basic', 'weight' => 2, 'callback' => [$this, 'check_site_title']],
258 ['id' => 'tagline', 'category' => 'basic', 'weight' => 1, 'callback' => [$this, 'check_tagline']],
259 ['id' => 'search_visibility', 'category' => 'basic', 'weight' => 3, 'callback' => [$this, 'check_search_visibility']],
260 ['id' => 'permalinks', 'category' => 'basic', 'weight' => 2, 'callback' => [$this, 'check_permalinks']],
261
262 // Advanced SEO
263 ['id' => 'xml_sitemap', 'category' => 'advanced', 'weight' => 2, 'callback' => [$this, 'check_sitemap']],
264 ['id' => 'schema', 'category' => 'advanced', 'weight' => 2, 'callback' => [$this, 'check_schema']],
265
266 // Content (bounded sample of published content)
267 ['id' => 'meta_descriptions', 'category' => 'content', 'weight' => 2, 'callback' => [$this, 'check_meta_descriptions']],
268 ['id' => 'image_alt_text', 'category' => 'content', 'weight' => 2, 'callback' => [$this, 'check_image_alt_text']],
269
270 // Performance & Technical
271 ['id' => 'php_version', 'category' => 'performance', 'weight' => 1, 'callback' => [$this, 'check_php_version']],
272 ['id' => 'object_cache', 'category' => 'performance', 'weight' => 1, 'callback' => [$this, 'check_object_cache']],
273
274 // Security
275 ['id' => 'https', 'category' => 'security', 'weight' => 3, 'callback' => [$this, 'check_https']],
276 ['id' => 'file_editing', 'category' => 'security', 'weight' => 2, 'callback' => [$this, 'check_file_editing']],
277 ['id' => 'debug_display', 'category' => 'security', 'weight' => 1, 'callback' => [$this, 'check_debug_display']],
278 ];
279
280 /**
281 * Filter the Site SEO Analyzer check registry.
282 *
283 * Each entry is an array with keys: id, category (basic|advanced|
284 * content|performance|security), weight (float), and callback (callable
285 * returning ['status' => passed|warning|failed, 'label', 'message',
286 * 'how_to_fix']).
287 *
288 * @since 1.18.0
289 *
290 * @param array $definitions Registered checks.
291 * @param SEO_Analyzer $analyzer The analyzer instance.
292 */
293 $definitions = apply_filters('thinkrank_seo_analyzer_checks', $definitions, $this);
294
295 return is_array($definitions) ? $definitions : [];
296 }
297
298 // ─────────────────────────────────────────────────────────────────────
299 // Basic SEO checks
300 // ─────────────────────────────────────────────────────────────────────
301
302 /**
303 * The site must have a name/title configured.
304 *
305 * @return array
306 */
307 public function check_site_title(): array {
308 $title = trim((string) get_bloginfo('name'));
309
310 if ($title === '') {
311 return [
312 'label' => __('Site title is set', 'thinkrank'),
313 'status' => self::FAILED,
314 'message' => __('Your site has no title. Search engines and browsers use it as your brand name.', 'thinkrank'),
315 'how_to_fix' => __('Set a site title under Settings → General → Site Title.', 'thinkrank'),
316 ];
317 }
318
319 return [
320 'label' => __('Site title is set', 'thinkrank'),
321 'status' => self::PASSED,
322 'message' => __('Your site title is configured.', 'thinkrank'),
323 'value' => $title,
324 ];
325 }
326
327 /**
328 * The tagline should be set and not left at the WordPress default.
329 *
330 * @return array
331 */
332 public function check_tagline(): array {
333 $tagline = trim((string) get_bloginfo('description'));
334 $is_default = strtolower($tagline) === strtolower('Just another WordPress site');
335
336 if ($tagline === '' || $is_default) {
337 return [
338 'label' => __('Tagline is customized', 'thinkrank'),
339 'status' => self::WARNING,
340 'message' => __('Your tagline is blank or still the WordPress default. Search engines may use it as your homepage description.', 'thinkrank'),
341 'how_to_fix' => __('Write a descriptive tagline under Settings → General → Tagline.', 'thinkrank'),
342 ];
343 }
344
345 return [
346 'label' => __('Tagline is customized', 'thinkrank'),
347 'status' => self::PASSED,
348 'message' => __('Your tagline is set and ready to describe your site.', 'thinkrank'),
349 'value' => $tagline,
350 ];
351 }
352
353 /**
354 * "Discourage search engines from indexing this site" must be OFF.
355 *
356 * @return array
357 */
358 public function check_search_visibility(): array {
359 // blog_public = 0 means the WP "Discourage search engines" box is ticked.
360 if (!get_option('blog_public')) {
361 return [
362 'label' => __('Site is visible to search engines', 'thinkrank'),
363 'status' => self::FAILED,
364 'message' => __('Your site is telling search engines not to index it — it will not appear in search results.', 'thinkrank'),
365 'how_to_fix' => __('Untick "Discourage search engines from indexing this site" under Settings → Reading.', 'thinkrank'),
366 ];
367 }
368
369 return [
370 'label' => __('Site is visible to search engines', 'thinkrank'),
371 'status' => self::PASSED,
372 'message' => __('Your site allows search engines to index it.', 'thinkrank'),
373 ];
374 }
375
376 /**
377 * Permalinks should be pretty (not the default plain ?p=123 structure).
378 *
379 * @return array
380 */
381 public function check_permalinks(): array {
382 $structure = (string) get_option('permalink_structure');
383
384 if ($structure === '') {
385 return [
386 'label' => __('Search-friendly permalinks', 'thinkrank'),
387 'status' => self::WARNING,
388 'message' => __('Your site uses plain, numeric URLs (e.g. ?p=123). Descriptive URLs are easier for search engines and users.', 'thinkrank'),
389 'how_to_fix' => __('Choose a pretty permalink structure (e.g. Post name) under Settings → Permalinks.', 'thinkrank'),
390 ];
391 }
392
393 return [
394 'label' => __('Search-friendly permalinks', 'thinkrank'),
395 'status' => self::PASSED,
396 'message' => __('Your permalinks are search-friendly.', 'thinkrank'),
397 'value' => $structure,
398 ];
399 }
400
401 // ─────────────────────────────────────────────────────────────────────
402 // Advanced SEO checks
403 // ─────────────────────────────────────────────────────────────────────
404
405 /**
406 * The ThinkRank XML sitemap should be enabled.
407 *
408 * @return array
409 */
410 public function check_sitemap(): array {
411 $enabled = true;
412 try {
413 $generator = new Sitemap_Generator();
414 // 'site' is the stored context; 'global' is unsupported and
415 // returns DEFAULTS (enabled=true), which made this check unable
416 // to fail no matter what the user configured.
417 $data = $generator->get_output_data('site', null);
418 $enabled = !empty($data['enabled']);
419 } catch (\Throwable $e) {
420 // Fall back to "enabled" — the default state — on any lookup error.
421 $enabled = true;
422 }
423
424 if (!$enabled) {
425 return [
426 'label' => __('XML sitemap is enabled', 'thinkrank'),
427 'status' => self::WARNING,
428 'message' => __('Your XML sitemap is turned off. Search engines rely on it to discover new pages quickly.', 'thinkrank'),
429 'how_to_fix' => __('Enable the XML sitemap under Essential SEO → Crawling & AI Indexing → XML Sitemap.', 'thinkrank'),
430 ];
431 }
432
433 return [
434 'label' => __('XML sitemap is enabled', 'thinkrank'),
435 'status' => self::PASSED,
436 'message' => __('Your XML sitemap is enabled and pointing crawlers to your content.', 'thinkrank'),
437 ];
438 }
439
440 /**
441 * Structured data (schema) should be configured for at least one post type.
442 *
443 * @return array
444 */
445 public function check_schema(): array {
446 $label = __('Structured data configured', 'thinkrank');
447
448 if ($this->schema_is_output()) {
449 return [
450 'label' => $label,
451 'status' => self::PASSED,
452 'message' => __('Structured data is configured for your content.', 'thinkrank'),
453 ];
454 }
455
456 return [
457 'label' => $label,
458 'status' => self::WARNING,
459 'message' => __('No schema/structured data is configured. Schema powers rich results in search.', 'thinkrank'),
460 'how_to_fix' => __('Choose a default schema type for each post type under Essential SEO → Bulk SEO Optimization.', 'thinkrank'),
461 ];
462 }
463
464 /**
465 * Whether ThinkRank actually emits structured data for this site.
466 *
467 * The audit must reflect what is rendered, not a single legacy option.
468 * ThinkRank outputs schema from two current sources, so this check consults
469 * both rather than the deprecated thinkrank_global_seo_settings['schema_type']
470 * opt-in (which most sites never set even though schema is emitted):
471 *
472 * 1. The Schema Management System — its configuration lives in the
473 * thinkrank_seo_settings table (context "schema_management_system"),
474 * read through the manager's settings abstraction.
475 * 2. The Global SEO output layer — an explicit saved schema_type OR the
476 * built-in per-post-type default both cause JSON-LD to be emitted on
477 * the frontend. We ask that layer directly (would_output_schema) so the
478 * audit and the rendered page can never diverge.
479 *
480 * @return bool True when structured data is emitted for the site's content.
481 */
482 private function schema_is_output(): bool {
483 // 1) Newer Schema Management System (thinkrank_seo_settings table).
484 if (class_exists('ThinkRank\\SEO\\Schema_Management_System')) {
485 $settings = (new Schema_Management_System())->get_settings('schema_management_system');
486 if (is_array($settings)) {
487 if (!empty($settings['enabled_schema_types']) && is_array($settings['enabled_schema_types'])) {
488 return true;
489 }
490 if (!empty($settings['auto_generate_schema'])) {
491 return true;
492 }
493 }
494 }
495
496 // 2) Global SEO output layer — explicit schema_type or per-post-type
497 // default. Reuse the output layer's own decision so audit == output.
498 if (class_exists('ThinkRank\\Frontend\\Global_SEO_Schema_Output')) {
499 $output = new \ThinkRank\Frontend\Global_SEO_Schema_Output();
500 foreach (get_post_types(['public' => true], 'names') as $post_type) {
501 if ($output->would_output_schema((string) $post_type)) {
502 return true;
503 }
504 }
505 }
506
507 return false;
508 }
509
510 // ─────────────────────────────────────────────────────────────────────
511 // Content checks (bounded sample of published content)
512 // ─────────────────────────────────────────────────────────────────────
513
514 /**
515 * How many recent published posts/pages the content checks sample.
516 */
517 private const CONTENT_SAMPLE_SIZE = 100;
518
519 /**
520 * Coverage thresholds shared by the content checks: at or above the first
521 * is a pass, at or above the second is a warning, below it a fail.
522 */
523 private const COVERAGE_PASS = 90;
524 private const COVERAGE_WARN = 50;
525
526 /**
527 * Recent published posts/pages should have meta descriptions.
528 *
529 * Samples the most recent CONTENT_SAMPLE_SIZE published posts/pages so the
530 * check stays fast on large sites.
531 *
532 * @return array
533 */
534 public function check_meta_descriptions(): array {
535 $label = __('Posts have meta descriptions', 'thinkrank');
536
537 $post_ids = get_posts([
538 'post_type' => ['post', 'page'],
539 'post_status' => 'publish',
540 'posts_per_page' => self::CONTENT_SAMPLE_SIZE,
541 'orderby' => 'date',
542 'order' => 'DESC',
543 'fields' => 'ids',
544 'no_found_rows' => true,
545 'suppress_filters' => false,
546 ]);
547
548 $total = count($post_ids);
549 if (0 === $total) {
550 return [
551 'label' => $label,
552 'status' => self::PASSED,
553 'message' => __('No published content to check yet.', 'thinkrank'),
554 ];
555 }
556
557 // Count posts with an *effective* meta description, the same way the
558 // frontend resolves it: a custom _thinkrank_meta_description when set,
559 // otherwise the global SEO pattern fallback (Pattern_Resolver). Counting
560 // only the custom post-meta produced false negatives — posts that output
561 // a valid description via the pattern fallback were wrongly reported as
562 // missing. The sample is bounded (CONTENT_SAMPLE_SIZE) so the per-post
563 // resolution stays cheap, and the whole analysis is cached for an hour.
564 $with_description = 0;
565 foreach ($post_ids as $post_id) {
566 $custom = (string) get_post_meta($post_id, '_thinkrank_meta_description', true);
567 $resolved = '' !== $custom ? $custom : Pattern_Resolver::description((int) $post_id);
568 if ('' !== trim($resolved)) {
569 $with_description++;
570 }
571 }
572
573 $coverage = (int) round(($with_description / $total) * 100);
574 $missing = $total - $with_description;
575 $value = sprintf('%d/%d', $with_description, $total);
576
577 if ($coverage >= self::COVERAGE_PASS) {
578 return [
579 'label' => $label,
580 'status' => self::PASSED,
581 /* translators: 1: posts with meta description, 2: sampled posts. */
582 'message' => sprintf(__('%1$d of your %2$d most recent posts have a meta description.', 'thinkrank'), $with_description, $total),
583 'value' => $value,
584 ];
585 }
586
587 return [
588 'label' => $label,
589 'status' => $coverage >= self::COVERAGE_WARN ? self::WARNING : self::FAILED,
590 /* translators: 1: posts missing a meta description, 2: sampled posts. */
591 '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),
592 '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'),
593 'value' => $value,
594 ];
595 }
596
597 /**
598 * Uploaded images should have alt text — it is an accessibility
599 * requirement and how image search understands your media.
600 *
601 * @return array
602 */
603 public function check_image_alt_text(): array {
604 $label = __('Images have alt text', 'thinkrank');
605
606 global $wpdb;
607 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- two indexed COUNTs; results are cached at the analysis level
608 $total = (int) $wpdb->get_var(
609 "SELECT COUNT(*) FROM {$wpdb->posts}
610 WHERE post_type = 'attachment'
611 AND post_mime_type LIKE 'image/%'
612 AND post_status != 'trash'"
613 );
614
615 if (0 === $total) {
616 return [
617 'label' => $label,
618 'status' => self::PASSED,
619 'message' => __('No images in your media library to check yet.', 'thinkrank'),
620 ];
621 }
622
623 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- indexed COUNT via postmeta meta_key index
624 $with_alt = (int) $wpdb->get_var(
625 "SELECT COUNT(DISTINCT p.ID) FROM {$wpdb->posts} p
626 INNER JOIN {$wpdb->postmeta} pm
627 ON pm.post_id = p.ID
628 AND pm.meta_key = '_wp_attachment_image_alt'
629 AND pm.meta_value != ''
630 WHERE p.post_type = 'attachment'
631 AND p.post_mime_type LIKE 'image/%'
632 AND p.post_status != 'trash'"
633 );
634
635 $coverage = (int) round(($with_alt / $total) * 100);
636 $missing = $total - $with_alt;
637 $value = sprintf('%d/%d', $with_alt, $total);
638
639 if ($coverage >= self::COVERAGE_PASS) {
640 return [
641 'label' => $label,
642 'status' => self::PASSED,
643 /* translators: 1: images with alt text, 2: total images. */
644 'message' => sprintf(__('%1$d of your %2$d images have alt text.', 'thinkrank'), $with_alt, $total),
645 'value' => $value,
646 ];
647 }
648
649 return [
650 'label' => $label,
651 'status' => $coverage >= self::COVERAGE_WARN ? self::WARNING : self::FAILED,
652 /* translators: 1: images missing alt text, 2: total images. */
653 '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),
654 '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'),
655 'value' => $value,
656 ];
657 }
658
659 // ─────────────────────────────────────────────────────────────────────
660 // Performance & Technical checks
661 // ─────────────────────────────────────────────────────────────────────
662
663 /**
664 * The site should run a supported PHP version.
665 *
666 * @return array
667 */
668 public function check_php_version(): array {
669 $current = PHP_VERSION;
670 // ThinkRank itself requires PHP 8.0 to run, so anything below 8.1 (the
671 // oldest actively-supported branch) is the meaningful warning line —
672 // a 7.x threshold here could never fire.
673 $supported = version_compare($current, '8.1', '>=');
674
675 if (!$supported) {
676 return [
677 'label' => __('Supported PHP version', 'thinkrank'),
678 'status' => self::WARNING,
679 /* translators: %s: current PHP version. */
680 'message' => sprintf(__('You are running PHP %s, which no longer receives active support. Newer PHP is faster and more secure.', 'thinkrank'), $current),
681 'how_to_fix' => __('Ask your host to upgrade to PHP 8.1 or newer.', 'thinkrank'),
682 'value' => $current,
683 ];
684 }
685
686 return [
687 'label' => __('Supported PHP version', 'thinkrank'),
688 'status' => self::PASSED,
689 /* translators: %s: current PHP version. */
690 'message' => sprintf(__('You are running a supported PHP version (%s).', 'thinkrank'), $current),
691 'value' => $current,
692 ];
693 }
694
695 /**
696 * A persistent object cache should be active for a faster, less DB-bound
697 * site.
698 *
699 * @return array
700 */
701 public function check_object_cache(): array {
702 if (function_exists('wp_using_ext_object_cache') && wp_using_ext_object_cache()) {
703 return [
704 'label' => __('Persistent object cache', 'thinkrank'),
705 'status' => self::PASSED,
706 'message' => __('A persistent object cache is active, reducing database load.', 'thinkrank'),
707 ];
708 }
709
710 return [
711 'label' => __('Persistent object cache', 'thinkrank'),
712 'status' => self::WARNING,
713 'message' => __('No persistent object cache is active. On busier sites this means more database queries per request.', 'thinkrank'),
714 'how_to_fix' => __('Enable a persistent object cache (e.g. Redis or Memcached) via your host or a caching plugin.', 'thinkrank'),
715 ];
716 }
717
718 // ─────────────────────────────────────────────────────────────────────
719 // Security checks
720 // ─────────────────────────────────────────────────────────────────────
721
722 /**
723 * The site should be served over HTTPS (SSL).
724 *
725 * @return array
726 */
727 public function check_https(): array {
728 $home = (string) get_option('home');
729 $uses_https = strpos($home, 'https://') === 0;
730
731 // WP 5.7+ can tell us the site is fully HTTPS-capable.
732 if (function_exists('wp_is_using_https')) {
733 $uses_https = $uses_https && wp_is_using_https();
734 }
735
736 if (!$uses_https) {
737 return [
738 'label' => __('Site uses HTTPS (SSL)', 'thinkrank'),
739 'status' => self::FAILED,
740 'message' => __('Your site URL is not served over HTTPS. HTTPS is a confirmed ranking signal and required for user trust.', 'thinkrank'),
741 'how_to_fix' => __('Install an SSL certificate and set your WordPress Address / Site Address to https:// under Settings → General.', 'thinkrank'),
742 ];
743 }
744
745 return [
746 'label' => __('Site uses HTTPS (SSL)', 'thinkrank'),
747 'status' => self::PASSED,
748 'message' => __('Your site is served securely over HTTPS.', 'thinkrank'),
749 ];
750 }
751
752 /**
753 * The built-in plugin/theme file editor should be disabled
754 * (DISALLOW_FILE_EDIT) so a compromised admin cannot edit PHP from wp-admin.
755 *
756 * @return array
757 */
758 public function check_file_editing(): array {
759 if (defined('DISALLOW_FILE_EDIT') && DISALLOW_FILE_EDIT) {
760 return [
761 'label' => __('File editing disabled', 'thinkrank'),
762 'status' => self::PASSED,
763 'message' => __('The dashboard plugin/theme file editor is disabled, reducing your attack surface.', 'thinkrank'),
764 ];
765 }
766
767 return [
768 'label' => __('File editing disabled', 'thinkrank'),
769 'status' => self::WARNING,
770 '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'),
771 'how_to_fix' => __('Add define(\'DISALLOW_FILE_EDIT\', true); to your wp-config.php.', 'thinkrank'),
772 ];
773 }
774
775 /**
776 * The site should not publicly display PHP errors (WP_DEBUG_DISPLAY),
777 * which can leak server paths and internals.
778 *
779 * @return array
780 */
781 public function check_debug_display(): array {
782 $debug = defined('WP_DEBUG') && WP_DEBUG;
783 // WP_DEBUG_DISPLAY only shows errors when it is on (its default) AND
784 // WP_DEBUG is enabled.
785 $display = !defined('WP_DEBUG_DISPLAY') || WP_DEBUG_DISPLAY;
786 $exposing = $debug && $display;
787
788 if ($exposing) {
789 return [
790 'label' => __('Errors not shown publicly', 'thinkrank'),
791 'status' => self::WARNING,
792 'message' => __('Debug output is displayed on the front end. Visible PHP errors can leak server paths and internals.', 'thinkrank'),
793 'how_to_fix' => __('Set define(\'WP_DEBUG_DISPLAY\', false); (or turn off WP_DEBUG) in wp-config.php on production.', 'thinkrank'),
794 ];
795 }
796
797 return [
798 'label' => __('Errors not shown publicly', 'thinkrank'),
799 'status' => self::PASSED,
800 'message' => __('PHP errors are not displayed to visitors.', 'thinkrank'),
801 ];
802 }
803 }
804