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