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

949 lines 39.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 * 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 * @since 2.2.0
570 * @return bool
571 */
572 private function schema_is_configured(): bool {
573 // 1) Schema Management System — an explicit opt-in.
574 if (class_exists('ThinkRank\\SEO\\Schema_Management_System')) {
575 $settings = (new Schema_Management_System())->get_settings('site', null);
576 if (is_array($settings)) {
577 if (!empty($settings['enabled_schema_types']) && is_array($settings['enabled_schema_types'])) {
578 return true;
579 }
580 if (!empty($settings['auto_generate_schema'])) {
581 return true;
582 }
583 }
584 }
585
586 // 2) A saved per-post-type schema_type in the Global SEO layer. The
587 // built-in default deliberately does not count here.
588 if (class_exists('ThinkRank\\Frontend\\Global_SEO_Schema_Output')) {
589 $output = new \ThinkRank\Frontend\Global_SEO_Schema_Output();
590 foreach (get_post_types(['public' => true], 'names') as $post_type) {
591 if ($output->has_explicit_schema_type((string) $post_type)) {
592 return true;
593 }
594 }
595 }
596
597 return false;
598 }
599
600 /**
601 * Whether ThinkRank actually emits structured data for this site.
602 *
603 * The audit must reflect what is rendered, not a single legacy option.
604 * ThinkRank outputs schema from two current sources, so this check consults
605 * both rather than the deprecated thinkrank_global_seo_settings['schema_type']
606 * opt-in (which most sites never set even though schema is emitted):
607 *
608 * 1. The Schema Management System — its configuration lives in the
609 * thinkrank_seo_settings table (context "schema_management_system"),
610 * read through the manager's settings abstraction.
611 * 2. The Global SEO output layer — an explicit saved schema_type OR the
612 * built-in per-post-type default both cause JSON-LD to be emitted on
613 * the frontend. We ask that layer directly (would_output_schema) so the
614 * audit and the rendered page can never diverge.
615 *
616 * @return bool True when structured data is emitted for the site's content.
617 */
618 private function schema_is_output(): bool {
619 // 1) Newer Schema Management System (thinkrank_seo_settings table).
620 if (class_exists('ThinkRank\\SEO\\Schema_Management_System')) {
621 // 'site' is the context type; 'schema_management_system' is the manager
622 // NAME, which get_settings() rejects as an unsupported context and
623 // answers with bare defaults — where auto_generate_schema is true, so
624 // this always returned true and never read the site's real settings (#473).
625 $settings = (new Schema_Management_System())->get_settings('site', null);
626 if (is_array($settings)) {
627 if (!empty($settings['enabled_schema_types']) && is_array($settings['enabled_schema_types'])) {
628 return true;
629 }
630 if (!empty($settings['auto_generate_schema'])) {
631 return true;
632 }
633 }
634 }
635
636 // 2) Global SEO output layer — explicit schema_type or per-post-type
637 // default. Reuse the output layer's own decision so audit == output.
638 if (class_exists('ThinkRank\\Frontend\\Global_SEO_Schema_Output')) {
639 $output = new \ThinkRank\Frontend\Global_SEO_Schema_Output();
640 foreach (get_post_types(['public' => true], 'names') as $post_type) {
641 if ($output->would_output_schema((string) $post_type)) {
642 return true;
643 }
644 }
645 }
646
647 return false;
648 }
649
650 // ─────────────────────────────────────────────────────────────────────
651 // Content checks (bounded sample of published content)
652 // ─────────────────────────────────────────────────────────────────────
653
654 /**
655 * How many recent published posts/pages the content checks sample.
656 */
657 private const CONTENT_SAMPLE_SIZE = 100;
658
659 /**
660 * Coverage thresholds shared by the content checks: at or above the first
661 * is a pass, at or above the second is a warning, below it a fail.
662 */
663 private const COVERAGE_PASS = 90;
664 private const COVERAGE_WARN = 50;
665
666 /**
667 * Recent published posts/pages should have meta descriptions.
668 *
669 * Samples the most recent CONTENT_SAMPLE_SIZE published posts/pages so the
670 * check stays fast on large sites.
671 *
672 * @return array
673 */
674 public function check_meta_descriptions(): array {
675 $label = __('Posts have meta descriptions', 'thinkrank');
676
677 $post_ids = get_posts([
678 'post_type' => ['post', 'page'],
679 'post_status' => 'publish',
680 'posts_per_page' => self::CONTENT_SAMPLE_SIZE,
681 'orderby' => 'date',
682 'order' => 'DESC',
683 'fields' => 'ids',
684 'no_found_rows' => true,
685 'suppress_filters' => false,
686 ]);
687
688 $total = count($post_ids);
689 if (0 === $total) {
690 return [
691 'label' => $label,
692 'status' => self::PASSED,
693 'message' => __('No published content to check yet.', 'thinkrank'),
694 ];
695 }
696
697 // Count posts with an *effective* meta description, the same way the
698 // frontend resolves it: a custom _thinkrank_meta_description when set,
699 // otherwise the global SEO pattern fallback (Pattern_Resolver). Counting
700 // only the custom post-meta produced false negatives — posts that output
701 // a valid description via the pattern fallback were wrongly reported as
702 // missing. The sample is bounded (CONTENT_SAMPLE_SIZE) so the per-post
703 // resolution stays cheap, and the whole analysis is cached for an hour.
704 // 'fields' => 'ids' skips WP_Query's meta priming, so the first
705 // get_post_meta() below would issue a query per post. Warm the whole
706 // sample once instead — 100 posts went from ~200 queries to a handful.
707 _prime_post_caches($post_ids, false, true);
708
709 $with_description = 0;
710 foreach ($post_ids as $post_id) {
711 $custom = (string) get_post_meta($post_id, '_thinkrank_meta_description', true);
712 $resolved = '' !== $custom ? $custom : Pattern_Resolver::description((int) $post_id);
713 if ('' !== trim($resolved)) {
714 $with_description++;
715 }
716 }
717
718 $coverage = (int) round(($with_description / $total) * 100);
719 $missing = $total - $with_description;
720 $value = sprintf('%d/%d', $with_description, $total);
721
722 if ($coverage >= self::COVERAGE_PASS) {
723 return [
724 'label' => $label,
725 'status' => self::PASSED,
726 /* translators: 1: posts with meta description, 2: sampled posts. */
727 'message' => sprintf(__('%1$d of your %2$d most recent posts have a meta description.', 'thinkrank'), $with_description, $total),
728 'value' => $value,
729 ];
730 }
731
732 return [
733 'label' => $label,
734 'status' => $coverage >= self::COVERAGE_WARN ? self::WARNING : self::FAILED,
735 /* translators: 1: posts missing a meta description, 2: sampled posts. */
736 '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),
737 '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'),
738 'value' => $value,
739 ];
740 }
741
742 /**
743 * Uploaded images should have alt text — it is an accessibility
744 * requirement and how image search understands your media.
745 *
746 * @return array
747 */
748 public function check_image_alt_text(): array {
749 $label = __('Images have alt text', 'thinkrank');
750
751 global $wpdb;
752 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- two indexed COUNTs; results are cached at the analysis level
753 $total = (int) $wpdb->get_var(
754 "SELECT COUNT(*) FROM {$wpdb->posts}
755 WHERE post_type = 'attachment'
756 AND post_mime_type LIKE 'image/%'
757 AND post_status != 'trash'"
758 );
759
760 if (0 === $total) {
761 return [
762 'label' => $label,
763 'status' => self::PASSED,
764 'message' => __('No images in your media library to check yet.', 'thinkrank'),
765 ];
766 }
767
768 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- indexed COUNT via postmeta meta_key index
769 $with_alt = (int) $wpdb->get_var(
770 "SELECT COUNT(DISTINCT p.ID) FROM {$wpdb->posts} p
771 INNER JOIN {$wpdb->postmeta} pm
772 ON pm.post_id = p.ID
773 AND pm.meta_key = '_wp_attachment_image_alt'
774 AND pm.meta_value != ''
775 WHERE p.post_type = 'attachment'
776 AND p.post_mime_type LIKE 'image/%'
777 AND p.post_status != 'trash'"
778 );
779
780 $coverage = (int) round(($with_alt / $total) * 100);
781 $missing = $total - $with_alt;
782 $value = sprintf('%d/%d', $with_alt, $total);
783
784 if ($coverage >= self::COVERAGE_PASS) {
785 return [
786 'label' => $label,
787 'status' => self::PASSED,
788 /* translators: 1: images with alt text, 2: total images. */
789 'message' => sprintf(__('%1$d of your %2$d images have alt text.', 'thinkrank'), $with_alt, $total),
790 'value' => $value,
791 ];
792 }
793
794 return [
795 'label' => $label,
796 'status' => $coverage >= self::COVERAGE_WARN ? self::WARNING : self::FAILED,
797 /* translators: 1: images missing alt text, 2: total images. */
798 '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),
799 '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'),
800 'value' => $value,
801 ];
802 }
803
804 // ─────────────────────────────────────────────────────────────────────
805 // Performance & Technical checks
806 // ─────────────────────────────────────────────────────────────────────
807
808 /**
809 * The site should run a supported PHP version.
810 *
811 * @return array
812 */
813 public function check_php_version(): array {
814 $current = PHP_VERSION;
815 // ThinkRank itself requires PHP 8.0 to run, so anything below 8.1 (the
816 // oldest actively-supported branch) is the meaningful warning line —
817 // a 7.x threshold here could never fire.
818 $supported = version_compare($current, '8.1', '>=');
819
820 if (!$supported) {
821 return [
822 'label' => __('Supported PHP version', 'thinkrank'),
823 'status' => self::WARNING,
824 /* translators: %s: current PHP version. */
825 'message' => sprintf(__('You are running PHP %s, which no longer receives active support. Newer PHP is faster and more secure.', 'thinkrank'), $current),
826 'how_to_fix' => __('Ask your host to upgrade to PHP 8.1 or newer.', 'thinkrank'),
827 'value' => $current,
828 ];
829 }
830
831 return [
832 'label' => __('Supported PHP version', 'thinkrank'),
833 'status' => self::PASSED,
834 /* translators: %s: current PHP version. */
835 'message' => sprintf(__('You are running a supported PHP version (%s).', 'thinkrank'), $current),
836 'value' => $current,
837 ];
838 }
839
840 /**
841 * A persistent object cache should be active for a faster, less DB-bound
842 * site.
843 *
844 * @return array
845 */
846 public function check_object_cache(): array {
847 if (function_exists('wp_using_ext_object_cache') && wp_using_ext_object_cache()) {
848 return [
849 'label' => __('Persistent object cache', 'thinkrank'),
850 'status' => self::PASSED,
851 'message' => __('A persistent object cache is active, reducing database load.', 'thinkrank'),
852 ];
853 }
854
855 return [
856 'label' => __('Persistent object cache', 'thinkrank'),
857 'status' => self::WARNING,
858 'message' => __('No persistent object cache is active. On busier sites this means more database queries per request.', 'thinkrank'),
859 'how_to_fix' => __('Enable a persistent object cache (e.g. Redis or Memcached) via your host or a caching plugin.', 'thinkrank'),
860 ];
861 }
862
863 // ─────────────────────────────────────────────────────────────────────
864 // Security checks
865 // ─────────────────────────────────────────────────────────────────────
866
867 /**
868 * The site should be served over HTTPS (SSL).
869 *
870 * @return array
871 */
872 public function check_https(): array {
873 $home = (string) get_option('home');
874 $uses_https = strpos($home, 'https://') === 0;
875
876 // WP 5.7+ can tell us the site is fully HTTPS-capable.
877 if (function_exists('wp_is_using_https')) {
878 $uses_https = $uses_https && wp_is_using_https();
879 }
880
881 if (!$uses_https) {
882 return [
883 'label' => __('Site uses HTTPS (SSL)', 'thinkrank'),
884 'status' => self::FAILED,
885 'message' => __('Your site URL is not served over HTTPS. HTTPS is a confirmed ranking signal and required for user trust.', 'thinkrank'),
886 'how_to_fix' => __('Install an SSL certificate and set your WordPress Address / Site Address to https:// under Settings → General.', 'thinkrank'),
887 ];
888 }
889
890 return [
891 'label' => __('Site uses HTTPS (SSL)', 'thinkrank'),
892 'status' => self::PASSED,
893 'message' => __('Your site is served securely over HTTPS.', 'thinkrank'),
894 ];
895 }
896
897 /**
898 * The built-in plugin/theme file editor should be disabled
899 * (DISALLOW_FILE_EDIT) so a compromised admin cannot edit PHP from wp-admin.
900 *
901 * @return array
902 */
903 public function check_file_editing(): array {
904 if (defined('DISALLOW_FILE_EDIT') && DISALLOW_FILE_EDIT) {
905 return [
906 'label' => __('File editing disabled', 'thinkrank'),
907 'status' => self::PASSED,
908 'message' => __('The dashboard plugin/theme file editor is disabled, reducing your attack surface.', 'thinkrank'),
909 ];
910 }
911
912 return [
913 'label' => __('File editing disabled', 'thinkrank'),
914 'status' => self::WARNING,
915 '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'),
916 'how_to_fix' => __('Add define(\'DISALLOW_FILE_EDIT\', true); to your wp-config.php.', 'thinkrank'),
917 ];
918 }
919
920 /**
921 * The site should not publicly display PHP errors (WP_DEBUG_DISPLAY),
922 * which can leak server paths and internals.
923 *
924 * @return array
925 */
926 public function check_debug_display(): array {
927 $debug = defined('WP_DEBUG') && WP_DEBUG;
928 // WP_DEBUG_DISPLAY only shows errors when it is on (its default) AND
929 // WP_DEBUG is enabled.
930 $display = !defined('WP_DEBUG_DISPLAY') || WP_DEBUG_DISPLAY;
931 $exposing = $debug && $display;
932
933 if ($exposing) {
934 return [
935 'label' => __('Errors not shown publicly', 'thinkrank'),
936 'status' => self::WARNING,
937 'message' => __('Debug output is displayed on the front end. Visible PHP errors can leak server paths and internals.', 'thinkrank'),
938 'how_to_fix' => __('Set define(\'WP_DEBUG_DISPLAY\', false); (or turn off WP_DEBUG) in wp-config.php on production.', 'thinkrank'),
939 ];
940 }
941
942 return [
943 'label' => __('Errors not shown publicly', 'thinkrank'),
944 'status' => self::PASSED,
945 'message' => __('PHP errors are not displayed to visitors.', 'thinkrank'),
946 ];
947 }
948 }
949