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

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