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

808 lines 33.5 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 // 'site' is the context type; 'schema_management_system' is the manager
486 // NAME, which get_settings() rejects as an unsupported context and
487 // answers with bare defaults — where auto_generate_schema is true, so
488 // this always returned true and never read the site's real settings (#473).
489 $settings = (new Schema_Management_System())->get_settings('site', null);
490 if (is_array($settings)) {
491 if (!empty($settings['enabled_schema_types']) && is_array($settings['enabled_schema_types'])) {
492 return true;
493 }
494 if (!empty($settings['auto_generate_schema'])) {
495 return true;
496 }
497 }
498 }
499
500 // 2) Global SEO output layer — explicit schema_type or per-post-type
501 // default. Reuse the output layer's own decision so audit == output.
502 if (class_exists('ThinkRank\\Frontend\\Global_SEO_Schema_Output')) {
503 $output = new \ThinkRank\Frontend\Global_SEO_Schema_Output();
504 foreach (get_post_types(['public' => true], 'names') as $post_type) {
505 if ($output->would_output_schema((string) $post_type)) {
506 return true;
507 }
508 }
509 }
510
511 return false;
512 }
513
514 // ─────────────────────────────────────────────────────────────────────
515 // Content checks (bounded sample of published content)
516 // ─────────────────────────────────────────────────────────────────────
517
518 /**
519 * How many recent published posts/pages the content checks sample.
520 */
521 private const CONTENT_SAMPLE_SIZE = 100;
522
523 /**
524 * Coverage thresholds shared by the content checks: at or above the first
525 * is a pass, at or above the second is a warning, below it a fail.
526 */
527 private const COVERAGE_PASS = 90;
528 private const COVERAGE_WARN = 50;
529
530 /**
531 * Recent published posts/pages should have meta descriptions.
532 *
533 * Samples the most recent CONTENT_SAMPLE_SIZE published posts/pages so the
534 * check stays fast on large sites.
535 *
536 * @return array
537 */
538 public function check_meta_descriptions(): array {
539 $label = __('Posts have meta descriptions', 'thinkrank');
540
541 $post_ids = get_posts([
542 'post_type' => ['post', 'page'],
543 'post_status' => 'publish',
544 'posts_per_page' => self::CONTENT_SAMPLE_SIZE,
545 'orderby' => 'date',
546 'order' => 'DESC',
547 'fields' => 'ids',
548 'no_found_rows' => true,
549 'suppress_filters' => false,
550 ]);
551
552 $total = count($post_ids);
553 if (0 === $total) {
554 return [
555 'label' => $label,
556 'status' => self::PASSED,
557 'message' => __('No published content to check yet.', 'thinkrank'),
558 ];
559 }
560
561 // Count posts with an *effective* meta description, the same way the
562 // frontend resolves it: a custom _thinkrank_meta_description when set,
563 // otherwise the global SEO pattern fallback (Pattern_Resolver). Counting
564 // only the custom post-meta produced false negatives — posts that output
565 // a valid description via the pattern fallback were wrongly reported as
566 // missing. The sample is bounded (CONTENT_SAMPLE_SIZE) so the per-post
567 // resolution stays cheap, and the whole analysis is cached for an hour.
568 $with_description = 0;
569 foreach ($post_ids as $post_id) {
570 $custom = (string) get_post_meta($post_id, '_thinkrank_meta_description', true);
571 $resolved = '' !== $custom ? $custom : Pattern_Resolver::description((int) $post_id);
572 if ('' !== trim($resolved)) {
573 $with_description++;
574 }
575 }
576
577 $coverage = (int) round(($with_description / $total) * 100);
578 $missing = $total - $with_description;
579 $value = sprintf('%d/%d', $with_description, $total);
580
581 if ($coverage >= self::COVERAGE_PASS) {
582 return [
583 'label' => $label,
584 'status' => self::PASSED,
585 /* translators: 1: posts with meta description, 2: sampled posts. */
586 'message' => sprintf(__('%1$d of your %2$d most recent posts have a meta description.', 'thinkrank'), $with_description, $total),
587 'value' => $value,
588 ];
589 }
590
591 return [
592 'label' => $label,
593 'status' => $coverage >= self::COVERAGE_WARN ? self::WARNING : self::FAILED,
594 /* translators: 1: posts missing a meta description, 2: sampled posts. */
595 '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),
596 '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'),
597 'value' => $value,
598 ];
599 }
600
601 /**
602 * Uploaded images should have alt text — it is an accessibility
603 * requirement and how image search understands your media.
604 *
605 * @return array
606 */
607 public function check_image_alt_text(): array {
608 $label = __('Images have alt text', 'thinkrank');
609
610 global $wpdb;
611 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- two indexed COUNTs; results are cached at the analysis level
612 $total = (int) $wpdb->get_var(
613 "SELECT COUNT(*) FROM {$wpdb->posts}
614 WHERE post_type = 'attachment'
615 AND post_mime_type LIKE 'image/%'
616 AND post_status != 'trash'"
617 );
618
619 if (0 === $total) {
620 return [
621 'label' => $label,
622 'status' => self::PASSED,
623 'message' => __('No images in your media library to check yet.', 'thinkrank'),
624 ];
625 }
626
627 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- indexed COUNT via postmeta meta_key index
628 $with_alt = (int) $wpdb->get_var(
629 "SELECT COUNT(DISTINCT p.ID) FROM {$wpdb->posts} p
630 INNER JOIN {$wpdb->postmeta} pm
631 ON pm.post_id = p.ID
632 AND pm.meta_key = '_wp_attachment_image_alt'
633 AND pm.meta_value != ''
634 WHERE p.post_type = 'attachment'
635 AND p.post_mime_type LIKE 'image/%'
636 AND p.post_status != 'trash'"
637 );
638
639 $coverage = (int) round(($with_alt / $total) * 100);
640 $missing = $total - $with_alt;
641 $value = sprintf('%d/%d', $with_alt, $total);
642
643 if ($coverage >= self::COVERAGE_PASS) {
644 return [
645 'label' => $label,
646 'status' => self::PASSED,
647 /* translators: 1: images with alt text, 2: total images. */
648 'message' => sprintf(__('%1$d of your %2$d images have alt text.', 'thinkrank'), $with_alt, $total),
649 'value' => $value,
650 ];
651 }
652
653 return [
654 'label' => $label,
655 'status' => $coverage >= self::COVERAGE_WARN ? self::WARNING : self::FAILED,
656 /* translators: 1: images missing alt text, 2: total images. */
657 '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),
658 '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'),
659 'value' => $value,
660 ];
661 }
662
663 // ─────────────────────────────────────────────────────────────────────
664 // Performance & Technical checks
665 // ─────────────────────────────────────────────────────────────────────
666
667 /**
668 * The site should run a supported PHP version.
669 *
670 * @return array
671 */
672 public function check_php_version(): array {
673 $current = PHP_VERSION;
674 // ThinkRank itself requires PHP 8.0 to run, so anything below 8.1 (the
675 // oldest actively-supported branch) is the meaningful warning line —
676 // a 7.x threshold here could never fire.
677 $supported = version_compare($current, '8.1', '>=');
678
679 if (!$supported) {
680 return [
681 'label' => __('Supported PHP version', 'thinkrank'),
682 'status' => self::WARNING,
683 /* translators: %s: current PHP version. */
684 'message' => sprintf(__('You are running PHP %s, which no longer receives active support. Newer PHP is faster and more secure.', 'thinkrank'), $current),
685 'how_to_fix' => __('Ask your host to upgrade to PHP 8.1 or newer.', 'thinkrank'),
686 'value' => $current,
687 ];
688 }
689
690 return [
691 'label' => __('Supported PHP version', 'thinkrank'),
692 'status' => self::PASSED,
693 /* translators: %s: current PHP version. */
694 'message' => sprintf(__('You are running a supported PHP version (%s).', 'thinkrank'), $current),
695 'value' => $current,
696 ];
697 }
698
699 /**
700 * A persistent object cache should be active for a faster, less DB-bound
701 * site.
702 *
703 * @return array
704 */
705 public function check_object_cache(): array {
706 if (function_exists('wp_using_ext_object_cache') && wp_using_ext_object_cache()) {
707 return [
708 'label' => __('Persistent object cache', 'thinkrank'),
709 'status' => self::PASSED,
710 'message' => __('A persistent object cache is active, reducing database load.', 'thinkrank'),
711 ];
712 }
713
714 return [
715 'label' => __('Persistent object cache', 'thinkrank'),
716 'status' => self::WARNING,
717 'message' => __('No persistent object cache is active. On busier sites this means more database queries per request.', 'thinkrank'),
718 'how_to_fix' => __('Enable a persistent object cache (e.g. Redis or Memcached) via your host or a caching plugin.', 'thinkrank'),
719 ];
720 }
721
722 // ─────────────────────────────────────────────────────────────────────
723 // Security checks
724 // ─────────────────────────────────────────────────────────────────────
725
726 /**
727 * The site should be served over HTTPS (SSL).
728 *
729 * @return array
730 */
731 public function check_https(): array {
732 $home = (string) get_option('home');
733 $uses_https = strpos($home, 'https://') === 0;
734
735 // WP 5.7+ can tell us the site is fully HTTPS-capable.
736 if (function_exists('wp_is_using_https')) {
737 $uses_https = $uses_https && wp_is_using_https();
738 }
739
740 if (!$uses_https) {
741 return [
742 'label' => __('Site uses HTTPS (SSL)', 'thinkrank'),
743 'status' => self::FAILED,
744 'message' => __('Your site URL is not served over HTTPS. HTTPS is a confirmed ranking signal and required for user trust.', 'thinkrank'),
745 'how_to_fix' => __('Install an SSL certificate and set your WordPress Address / Site Address to https:// under Settings → General.', 'thinkrank'),
746 ];
747 }
748
749 return [
750 'label' => __('Site uses HTTPS (SSL)', 'thinkrank'),
751 'status' => self::PASSED,
752 'message' => __('Your site is served securely over HTTPS.', 'thinkrank'),
753 ];
754 }
755
756 /**
757 * The built-in plugin/theme file editor should be disabled
758 * (DISALLOW_FILE_EDIT) so a compromised admin cannot edit PHP from wp-admin.
759 *
760 * @return array
761 */
762 public function check_file_editing(): array {
763 if (defined('DISALLOW_FILE_EDIT') && DISALLOW_FILE_EDIT) {
764 return [
765 'label' => __('File editing disabled', 'thinkrank'),
766 'status' => self::PASSED,
767 'message' => __('The dashboard plugin/theme file editor is disabled, reducing your attack surface.', 'thinkrank'),
768 ];
769 }
770
771 return [
772 'label' => __('File editing disabled', 'thinkrank'),
773 'status' => self::WARNING,
774 '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'),
775 'how_to_fix' => __('Add define(\'DISALLOW_FILE_EDIT\', true); to your wp-config.php.', 'thinkrank'),
776 ];
777 }
778
779 /**
780 * The site should not publicly display PHP errors (WP_DEBUG_DISPLAY),
781 * which can leak server paths and internals.
782 *
783 * @return array
784 */
785 public function check_debug_display(): array {
786 $debug = defined('WP_DEBUG') && WP_DEBUG;
787 // WP_DEBUG_DISPLAY only shows errors when it is on (its default) AND
788 // WP_DEBUG is enabled.
789 $display = !defined('WP_DEBUG_DISPLAY') || WP_DEBUG_DISPLAY;
790 $exposing = $debug && $display;
791
792 if ($exposing) {
793 return [
794 'label' => __('Errors not shown publicly', 'thinkrank'),
795 'status' => self::WARNING,
796 'message' => __('Debug output is displayed on the front end. Visible PHP errors can leak server paths and internals.', 'thinkrank'),
797 'how_to_fix' => __('Set define(\'WP_DEBUG_DISPLAY\', false); (or turn off WP_DEBUG) in wp-config.php on production.', 'thinkrank'),
798 ];
799 }
800
801 return [
802 'label' => __('Errors not shown publicly', 'thinkrank'),
803 'status' => self::PASSED,
804 'message' => __('PHP errors are not displayed to visitors.', 'thinkrank'),
805 ];
806 }
807 }
808