| 1 |
<?php |
| 2 |
namespace ABlocks\Classes\Scanner; |
| 3 |
|
| 4 |
if ( ! defined( 'ABSPATH' ) ) { |
| 5 |
exit; |
| 6 |
} |
| 7 |
|
| 8 |
use ABlocks\Helper; |
| 9 |
use ABlocks\Classes\CacheBackend; |
| 10 |
use ABlocks\Classes\Images\UnusedScanner; |
| 11 |
use ABlocks\Classes\FontCollector; |
| 12 |
use ABlocks\Performance\ImageTools; |
| 13 |
|
| 14 |
/** |
| 15 |
* Site scanner — find what is holding a site back, and say how to fix it. |
| 16 |
* |
| 17 |
* Every check returns the same shape so the UI never has to special-case one: |
| 18 |
* what was found, why it matters, and what to do about it. The "why" is not |
| 19 |
* decoration — a warning a site owner cannot act on is noise, and noise is what |
| 20 |
* makes people stop reading these screens. |
| 21 |
* |
| 22 |
* Three rules the checks follow: |
| 23 |
* |
| 24 |
* - Say the number. "3 of 214 images optimized" is actionable; "images could be |
| 25 |
* optimized" is not. |
| 26 |
* - Never invent severity. A setting that is off on purpose is not a failure, |
| 27 |
* so preferences are reported as suggestions and only genuine faults — |
| 28 |
* blocked search engines, no HTTPS, two caching plugins fighting — are |
| 29 |
* raised as critical. |
| 30 |
* - Only claim what was actually measured. Checks that cannot run on this |
| 31 |
* install say so rather than guessing. |
| 32 |
*/ |
| 33 |
class Scanner { |
| 34 |
|
| 35 |
const CRITICAL = 'critical'; |
| 36 |
const WARNING = 'warning'; |
| 37 |
const GOOD = 'good'; |
| 38 |
const INFO = 'info'; |
| 39 |
|
| 40 |
/** |
| 41 |
* Caching plugins that would fight with the page cache. |
| 42 |
* |
| 43 |
* Keyed by the plugin's main file so activation is checked exactly, not by |
| 44 |
* guessing from a folder name. |
| 45 |
*/ |
| 46 |
const CONFLICTING = [ |
| 47 |
'wp-rocket/wp-rocket.php' => 'WP Rocket', |
| 48 |
'litespeed-cache/litespeed-cache.php' => 'LiteSpeed Cache', |
| 49 |
'w3-total-cache/w3-total-cache.php' => 'W3 Total Cache', |
| 50 |
'wp-super-cache/wp-cache.php' => 'WP Super Cache', |
| 51 |
'wp-fastest-cache/wpFastestCache.php' => 'WP Fastest Cache', |
| 52 |
'cache-enabler/cache-enabler.php' => 'Cache Enabler', |
| 53 |
'breeze/breeze.php' => 'Breeze', |
| 54 |
'sg-cachepress/sg-cachepress.php' => 'SG Optimizer', |
| 55 |
'hummingbird-performance/wp-hummingbird.php' => 'Hummingbird', |
| 56 |
]; |
| 57 |
|
| 58 |
/** |
| 59 |
* Where dismissed check ids are stored. |
| 60 |
*/ |
| 61 |
const DISMISSED_OPTION = 'ablocks_scanner_dismissed'; |
| 62 |
|
| 63 |
/** |
| 64 |
* Where past scores are kept, for the trend line. |
| 65 |
*/ |
| 66 |
const HISTORY_OPTION = 'ablocks_scanner_history'; |
| 67 |
|
| 68 |
/** |
| 69 |
* How much each check moves the score. |
| 70 |
* |
| 71 |
* Weighted rather than counted, because a site with search engines blocked |
| 72 |
* and everything else perfect is not "95% healthy". Anything not listed |
| 73 |
* scores 1. |
| 74 |
*/ |
| 75 |
const WEIGHTS = [ |
| 76 |
'search_visibility' => 10, |
| 77 |
'https' => 10, |
| 78 |
'cache_conflict' => 8, |
| 79 |
'page_cache' => 6, |
| 80 |
'asset_generation' => 4, |
| 81 |
'css_consolidation' => 3, |
| 82 |
'images_unoptimized' => 4, |
| 83 |
'images_oversized' => 3, |
| 84 |
'lazy_images' => 3, |
| 85 |
'font_load' => 3, |
| 86 |
'font_local' => 2, |
| 87 |
'repeated_colors' => 2, |
| 88 |
'repeated_typography' => 2, |
| 89 |
'images_alt' => 3, |
| 90 |
'php_version' => 4, |
| 91 |
'permalinks' => 4, |
| 92 |
'wp_cron' => 3, |
| 93 |
'file_editing' => 2, |
| 94 |
'autoloaded_options' => 3, |
| 95 |
]; |
| 96 |
|
| 97 |
/** |
| 98 |
* Run every check. |
| 99 |
* |
| 100 |
* @return array{checks:array, summary:array, score:array} |
| 101 |
*/ |
| 102 |
public static function run() { |
| 103 |
$checks = []; |
| 104 |
|
| 105 |
foreach ( self::checks() as $method ) { |
| 106 |
$result = self::$method(); |
| 107 |
if ( is_array( $result ) && ! empty( $result['id'] ) ) { |
| 108 |
$checks[] = $result; |
| 109 |
} |
| 110 |
} |
| 111 |
|
| 112 |
$checks = (array) apply_filters( 'ablocks/scanner/checks', $checks ); |
| 113 |
|
| 114 |
// Dismissed checks stay in the payload so the UI can list and undo them, |
| 115 |
// but they take no part in the score — an issue the site owner has |
| 116 |
// consciously accepted should not keep dragging the number down. |
| 117 |
$dismissed = self::dismissed(); |
| 118 |
foreach ( $checks as &$check ) { |
| 119 |
$check['weight'] = isset( self::WEIGHTS[ $check['id'] ] ) ? (int) self::WEIGHTS[ $check['id'] ] : 1; |
| 120 |
$check['dismissed'] = in_array( $check['id'], $dismissed, true ); |
| 121 |
} |
| 122 |
unset( $check ); |
| 123 |
|
| 124 |
// Worst first: a screen that opens on "everything is fine" buries the one |
| 125 |
// thing that is not. |
| 126 |
$rank = [ |
| 127 |
self::CRITICAL => 0, |
| 128 |
self::WARNING => 1, |
| 129 |
self::INFO => 2, |
| 130 |
self::GOOD => 3, |
| 131 |
]; |
| 132 |
usort( |
| 133 |
$checks, |
| 134 |
function ( $a, $b ) use ( $rank ) { |
| 135 |
$sa = isset( $rank[ $a['status'] ] ) ? $rank[ $a['status'] ] : 4; |
| 136 |
$sb = isset( $rank[ $b['status'] ] ) ? $rank[ $b['status'] ] : 4; |
| 137 |
return $sa <=> $sb; |
| 138 |
} |
| 139 |
); |
| 140 |
|
| 141 |
$summary = [ |
| 142 |
self::CRITICAL => 0, |
| 143 |
self::WARNING => 0, |
| 144 |
self::INFO => 0, |
| 145 |
self::GOOD => 0, |
| 146 |
]; |
| 147 |
foreach ( $checks as $check ) { |
| 148 |
if ( isset( $summary[ $check['status'] ] ) ) { |
| 149 |
$summary[ $check['status'] ]++; |
| 150 |
} |
| 151 |
} |
| 152 |
|
| 153 |
$score = self::score( $checks ); |
| 154 |
|
| 155 |
self::remember_score( $score['score'] ); |
| 156 |
|
| 157 |
return [ |
| 158 |
'checks' => $checks, |
| 159 |
'summary' => $summary, |
| 160 |
'score' => $score, |
| 161 |
'history' => self::history(), |
| 162 |
'scanned' => time(), |
| 163 |
]; |
| 164 |
} |
| 165 |
|
| 166 |
/** |
| 167 |
* Turn the results into a score out of 100, overall and per category. |
| 168 |
* |
| 169 |
* Informational checks are left out of the maths entirely. They report |
| 170 |
* something worth knowing rather than something wrong, so counting them |
| 171 |
* either way would move the number for no reason — a site cannot "fix" not |
| 172 |
* having Redis, and should not be marked down for it. |
| 173 |
* |
| 174 |
* @param array $checks Completed checks. |
| 175 |
* @return array{score:int, grade:string, categories:array} |
| 176 |
*/ |
| 177 |
private static function score( $checks ) { |
| 178 |
$earned = 0; |
| 179 |
$total = 0; |
| 180 |
$cats = []; |
| 181 |
|
| 182 |
foreach ( $checks as $check ) { |
| 183 |
if ( self::INFO === $check['status'] || ! empty( $check['dismissed'] ) ) { |
| 184 |
continue; |
| 185 |
} |
| 186 |
|
| 187 |
$weight = (int) $check['weight']; |
| 188 |
$points = self::GOOD === $check['status'] |
| 189 |
? $weight |
| 190 |
// A warning is a partial pass: the site works, it is just not |
| 191 |
// doing as well as it could. Scoring it zero would make one |
| 192 |
// suggestion look as bad as a broken site. |
| 193 |
: ( self::WARNING === $check['status'] ? $weight * 0.4 : 0 ); |
| 194 |
|
| 195 |
$earned += $points; |
| 196 |
$total += $weight; |
| 197 |
|
| 198 |
$cat = $check['category']; |
| 199 |
if ( ! isset( $cats[ $cat ] ) ) { |
| 200 |
$cats[ $cat ] = [ |
| 201 |
'earned' => 0, |
| 202 |
'total' => 0, |
| 203 |
]; |
| 204 |
} |
| 205 |
$cats[ $cat ]['earned'] += $points; |
| 206 |
$cats[ $cat ]['total'] += $weight; |
| 207 |
}//end foreach |
| 208 |
|
| 209 |
$percent = $total > 0 ? (int) round( 100 * $earned / $total ) : 100; |
| 210 |
|
| 211 |
$categories = []; |
| 212 |
foreach ( $cats as $name => $data ) { |
| 213 |
$categories[ $name ] = $data['total'] > 0 |
| 214 |
? (int) round( 100 * $data['earned'] / $data['total'] ) |
| 215 |
: 100; |
| 216 |
} |
| 217 |
arsort( $categories ); |
| 218 |
|
| 219 |
return [ |
| 220 |
'score' => $percent, |
| 221 |
'grade' => self::grade( $percent ), |
| 222 |
'categories' => $categories, |
| 223 |
]; |
| 224 |
} |
| 225 |
|
| 226 |
/** |
| 227 |
* Letter for a score. |
| 228 |
* |
| 229 |
* @param int $percent Score out of 100. |
| 230 |
* @return string |
| 231 |
*/ |
| 232 |
private static function grade( $percent ) { |
| 233 |
if ( $percent >= 90 ) { |
| 234 |
return 'A'; |
| 235 |
} |
| 236 |
if ( $percent >= 80 ) { |
| 237 |
return 'B'; |
| 238 |
} |
| 239 |
if ( $percent >= 70 ) { |
| 240 |
return 'C'; |
| 241 |
} |
| 242 |
if ( $percent >= 60 ) { |
| 243 |
return 'D'; |
| 244 |
} |
| 245 |
return 'F'; |
| 246 |
} |
| 247 |
|
| 248 |
/** |
| 249 |
* Record today's score so the UI can show whether things are improving. |
| 250 |
* |
| 251 |
* One entry per day, because the value of a trend line is direction over |
| 252 |
* weeks; storing every scan would fill the row with a dozen identical |
| 253 |
* numbers from one afternoon of tinkering. |
| 254 |
* |
| 255 |
* @param int $percent Score out of 100. |
| 256 |
*/ |
| 257 |
private static function remember_score( $percent ) { |
| 258 |
$history = self::history(); |
| 259 |
$today = gmdate( 'Y-m-d' ); |
| 260 |
|
| 261 |
$history[ $today ] = (int) $percent; |
| 262 |
|
| 263 |
if ( count( $history ) > 30 ) { |
| 264 |
$history = array_slice( $history, -30, 30, true ); |
| 265 |
} |
| 266 |
|
| 267 |
update_option( self::HISTORY_OPTION, $history, false ); |
| 268 |
} |
| 269 |
|
| 270 |
/** |
| 271 |
* Past scores, oldest first. |
| 272 |
* |
| 273 |
* @return array<string,int> |
| 274 |
*/ |
| 275 |
public static function history() { |
| 276 |
$history = get_option( self::HISTORY_OPTION, [] ); |
| 277 |
return is_array( $history ) ? $history : []; |
| 278 |
} |
| 279 |
|
| 280 |
/** |
| 281 |
* Check ids the site owner has chosen to ignore. |
| 282 |
* |
| 283 |
* @return string[] |
| 284 |
*/ |
| 285 |
public static function dismissed() { |
| 286 |
$list = get_option( self::DISMISSED_OPTION, [] ); |
| 287 |
return is_array( $list ) ? array_values( array_map( 'strval', $list ) ) : []; |
| 288 |
} |
| 289 |
|
| 290 |
/** |
| 291 |
* Ignore or un-ignore a check. |
| 292 |
* |
| 293 |
* @param string $id Check id. |
| 294 |
* @param bool $dismiss True to ignore, false to bring it back. |
| 295 |
* @return bool |
| 296 |
*/ |
| 297 |
public static function set_dismissed( $id, $dismiss = true ) { |
| 298 |
$id = sanitize_key( $id ); |
| 299 |
if ( '' === $id ) { |
| 300 |
return false; |
| 301 |
} |
| 302 |
|
| 303 |
$list = self::dismissed(); |
| 304 |
|
| 305 |
if ( $dismiss ) { |
| 306 |
if ( ! in_array( $id, $list, true ) ) { |
| 307 |
$list[] = $id; |
| 308 |
} |
| 309 |
} else { |
| 310 |
$list = array_values( array_diff( $list, [ $id ] ) ); |
| 311 |
} |
| 312 |
|
| 313 |
update_option( self::DISMISSED_OPTION, $list, false ); |
| 314 |
|
| 315 |
return true; |
| 316 |
} |
| 317 |
|
| 318 |
/** |
| 319 |
* The checks to run, in definition order. |
| 320 |
* |
| 321 |
* @return string[] |
| 322 |
*/ |
| 323 |
private static function checks() { |
| 324 |
return [ |
| 325 |
'check_search_visibility', |
| 326 |
'check_https', |
| 327 |
'check_conflicting_cache', |
| 328 |
'check_page_cache', |
| 329 |
'check_object_cache', |
| 330 |
'check_css_consolidation', |
| 331 |
'check_crawler', |
| 332 |
'check_asset_generation', |
| 333 |
'check_defer_js', |
| 334 |
'check_images_unoptimized', |
| 335 |
'check_images_oversized', |
| 336 |
'check_images_alt_text', |
| 337 |
'check_images_unused', |
| 338 |
'check_lazy_loading', |
| 339 |
'check_repeated_colors', |
| 340 |
'check_repeated_typography', |
| 341 |
'check_font_families', |
| 342 |
'check_font_local', |
| 343 |
'check_font_fallback', |
| 344 |
'check_php_version', |
| 345 |
'check_debug_mode', |
| 346 |
'check_autoloaded_options', |
| 347 |
'check_permalinks', |
| 348 |
'check_revisions', |
| 349 |
'check_wp_cron', |
| 350 |
'check_sitemap', |
| 351 |
'check_site_icon', |
| 352 |
'check_inactive_plugins', |
| 353 |
'check_file_editing', |
| 354 |
'check_expired_transients', |
| 355 |
]; |
| 356 |
} |
| 357 |
|
| 358 |
/** |
| 359 |
* Assemble one result. |
| 360 |
* |
| 361 |
* @param array $args Check fields. |
| 362 |
* @return array |
| 363 |
*/ |
| 364 |
private static function result( $args ) { |
| 365 |
return wp_parse_args( |
| 366 |
$args, |
| 367 |
[ |
| 368 |
'id' => '', |
| 369 |
'label' => '', |
| 370 |
'category' => 'general', |
| 371 |
'status' => self::INFO, |
| 372 |
'summary' => '', |
| 373 |
'why' => '', |
| 374 |
'fix' => '', |
| 375 |
'field' => '', |
| 376 |
'url' => '', |
| 377 |
] |
| 378 |
); |
| 379 |
} |
| 380 |
|
| 381 |
// -- Site-level faults --------------------------------------------------- |
| 382 |
|
| 383 |
/** |
| 384 |
* Search engine visibility. |
| 385 |
*/ |
| 386 |
private static function check_search_visibility() { |
| 387 |
$blocked = ! (int) get_option( 'blog_public', 1 ); |
| 388 |
|
| 389 |
return self::result( |
| 390 |
[ |
| 391 |
'id' => 'search_visibility', |
| 392 |
'label' => __( 'Search engine visibility', 'ablocks' ), |
| 393 |
'category' => 'seo', |
| 394 |
'status' => $blocked ? self::CRITICAL : self::GOOD, |
| 395 |
'summary' => $blocked |
| 396 |
? __( 'Search engines are being asked not to index this site', 'ablocks' ) |
| 397 |
: __( 'Search engines can index this site', 'ablocks' ), |
| 398 |
'why' => $blocked |
| 399 |
? __( 'This setting is normally switched on while a site is being built and forgotten at launch. While it is on, Google is asked to stay away — no amount of speed or content work will bring in search traffic.', 'ablocks' ) |
| 400 |
: '', |
| 401 |
'fix' => $blocked |
| 402 |
? __( 'Settings → Reading → untick "Discourage search engines from indexing this site".', 'ablocks' ) |
| 403 |
: '', |
| 404 |
'url' => $blocked ? admin_url( 'options-reading.php' ) : '', |
| 405 |
] |
| 406 |
); |
| 407 |
} |
| 408 |
|
| 409 |
/** |
| 410 |
* HTTPS. |
| 411 |
*/ |
| 412 |
private static function check_https() { |
| 413 |
$secure = 0 === strpos( home_url(), 'https://' ); |
| 414 |
|
| 415 |
return self::result( |
| 416 |
[ |
| 417 |
'id' => 'https', |
| 418 |
'label' => __( 'Secure connection', 'ablocks' ), |
| 419 |
'category' => 'seo', |
| 420 |
'status' => $secure ? self::GOOD : self::CRITICAL, |
| 421 |
'summary' => $secure |
| 422 |
? __( 'The site is served over HTTPS', 'ablocks' ) |
| 423 |
: __( 'The site is not served over HTTPS', 'ablocks' ), |
| 424 |
'why' => $secure |
| 425 |
? '' |
| 426 |
: __( 'Browsers mark plain HTTP pages as "Not secure", search engines rank them lower, and several speed features — including HTTP/2 and modern image formats over CDN — depend on it.', 'ablocks' ), |
| 427 |
'fix' => $secure ? '' : __( 'Install an SSL certificate with your host, then update the WordPress and Site Address to https:// under Settings → General.', 'ablocks' ), |
| 428 |
] |
| 429 |
); |
| 430 |
} |
| 431 |
|
| 432 |
/** |
| 433 |
* Another caching plugin running alongside this one. |
| 434 |
*/ |
| 435 |
private static function check_conflicting_cache() { |
| 436 |
// is_plugin_active() lives in an admin include that is absent from AJAX |
| 437 |
// and cron. Guarded here rather than only at the caller, because the |
| 438 |
// scanner is callable from anywhere and a missing function would be a |
| 439 |
// fatal error rather than a skipped check. |
| 440 |
if ( ! function_exists( 'is_plugin_active' ) ) { |
| 441 |
if ( ! is_readable( ABSPATH . 'wp-admin/includes/plugin.php' ) ) { |
| 442 |
return []; |
| 443 |
} |
| 444 |
require_once ABSPATH . 'wp-admin/includes/plugin.php'; |
| 445 |
} |
| 446 |
|
| 447 |
$found = []; |
| 448 |
foreach ( self::CONFLICTING as $plugin => $name ) { |
| 449 |
if ( is_plugin_active( $plugin ) ) { |
| 450 |
$found[] = $name; |
| 451 |
} |
| 452 |
} |
| 453 |
|
| 454 |
$has = ! empty( $found ); |
| 455 |
|
| 456 |
return self::result( |
| 457 |
[ |
| 458 |
'id' => 'cache_conflict', |
| 459 |
'label' => __( 'Competing caching plugins', 'ablocks' ), |
| 460 |
'category' => 'caching', |
| 461 |
'status' => $has ? self::CRITICAL : self::GOOD, |
| 462 |
'summary' => $has |
| 463 |
/* translators: %s: comma-separated plugin names. */ |
| 464 |
? sprintf( __( '%s is also caching pages', 'ablocks' ), implode( ', ', $found ) ) |
| 465 |
: __( 'No competing caching plugin found', 'ablocks' ), |
| 466 |
'why' => $has |
| 467 |
? __( 'Two page caches on one site fight each other: each stores what the other produced, purges do not reach both, and stale pages become almost impossible to trace. This is the single most common cause of "I cleared the cache and nothing changed".', 'ablocks' ) |
| 468 |
: '', |
| 469 |
'fix' => $has |
| 470 |
? __( 'Pick one. Either switch off page caching in the other plugin, or leave aBlocks caching off and use theirs — both work, running both does not.', 'ablocks' ) |
| 471 |
: '', |
| 472 |
'url' => $has ? admin_url( 'plugins.php' ) : '', |
| 473 |
] |
| 474 |
); |
| 475 |
} |
| 476 |
|
| 477 |
// -- Caching ------------------------------------------------------------- |
| 478 |
|
| 479 |
/** |
| 480 |
* Page cache enabled. |
| 481 |
*/ |
| 482 |
private static function check_page_cache() { |
| 483 |
$on = (bool) Helper::get_settings( 'perf_page_cache', false ); |
| 484 |
|
| 485 |
return self::result( |
| 486 |
[ |
| 487 |
'id' => 'page_cache', |
| 488 |
'label' => __( 'Full-page cache', 'ablocks' ), |
| 489 |
'category' => 'caching', |
| 490 |
'status' => $on ? self::GOOD : self::WARNING, |
| 491 |
'summary' => $on ? __( 'Enabled', 'ablocks' ) : __( 'Not enabled', 'ablocks' ), |
| 492 |
'why' => $on |
| 493 |
? '' |
| 494 |
: __( 'Without it every visitor waits for WordPress to rebuild the page from scratch — loading plugins, parsing blocks and querying the database — even though the result is identical for everyone. This is usually the single biggest speed gain available to a site.', 'ablocks' ), |
| 495 |
'fix' => $on ? '' : __( 'Turn on "Full-page cache" under Performance → Caching.', 'ablocks' ), |
| 496 |
'field' => $on ? '' : 'perf_page_cache', |
| 497 |
] |
| 498 |
); |
| 499 |
} |
| 500 |
|
| 501 |
/** |
| 502 |
* Persistent object cache. |
| 503 |
*/ |
| 504 |
private static function check_object_cache() { |
| 505 |
$has = CacheBackend::is_persistent(); |
| 506 |
|
| 507 |
return self::result( |
| 508 |
[ |
| 509 |
'id' => 'object_cache', |
| 510 |
'label' => __( 'Persistent object cache', 'ablocks' ), |
| 511 |
'category' => 'caching', |
| 512 |
'status' => $has ? self::GOOD : self::INFO, |
| 513 |
'summary' => $has |
| 514 |
? __( 'Available', 'ablocks' ) |
| 515 |
: __( 'Not available (Redis or Memcached)', 'ablocks' ), |
| 516 |
'why' => $has |
| 517 |
? '' |
| 518 |
: __( 'WordPress repeats the same database queries on every request unless something remembers them between requests. Without one, some optimizations are not worth enabling — the template cache stays off for exactly this reason, because storing lookups in the database costs more than it saves.', 'ablocks' ), |
| 519 |
'fix' => $has ? '' : __( 'Ask your host whether Redis or Memcached is available; most managed WordPress hosts offer it as a one-click add-on. This is optional — the site works well without it.', 'ablocks' ), |
| 520 |
] |
| 521 |
); |
| 522 |
} |
| 523 |
|
| 524 |
/** |
| 525 |
* Inline CSS consolidation. |
| 526 |
*/ |
| 527 |
private static function check_css_consolidation() { |
| 528 |
$on = (bool) Helper::get_settings( 'perf_consolidate_css', false ); |
| 529 |
|
| 530 |
return self::result( |
| 531 |
[ |
| 532 |
'id' => 'css_consolidation', |
| 533 |
'label' => __( 'Inline CSS moved to cached files', 'ablocks' ), |
| 534 |
'category' => 'assets', |
| 535 |
'status' => $on ? self::GOOD : self::WARNING, |
| 536 |
'summary' => $on ? __( 'Enabled', 'ablocks' ) : __( 'Not enabled', 'ablocks' ), |
| 537 |
'why' => $on |
| 538 |
? '' |
| 539 |
: __( 'Block themes print tens of kilobytes of CSS directly into every page, and the browser cannot reuse any of it when the visitor clicks through to the next page. Measured on Twenty Twenty-Four, that is around 40% of the page weight downloaded again and again.', 'ablocks' ), |
| 540 |
'fix' => $on ? '' : __( 'Turn on "Move inline CSS into cached files" under Performance → Caching.', 'ablocks' ), |
| 541 |
'field' => $on ? '' : 'perf_consolidate_css', |
| 542 |
] |
| 543 |
); |
| 544 |
} |
| 545 |
|
| 546 |
/** |
| 547 |
* Background crawler. |
| 548 |
*/ |
| 549 |
private static function check_crawler() { |
| 550 |
$cache_on = (bool) Helper::get_settings( 'perf_page_cache', false ); |
| 551 |
$on = (bool) Helper::get_settings( 'perf_page_cache_crawler', false ); |
| 552 |
|
| 553 |
if ( ! $cache_on ) { |
| 554 |
return []; |
| 555 |
} |
| 556 |
|
| 557 |
return self::result( |
| 558 |
[ |
| 559 |
'id' => 'crawler', |
| 560 |
'label' => __( 'Background page caching', 'ablocks' ), |
| 561 |
'category' => 'caching', |
| 562 |
'status' => $on ? self::GOOD : self::INFO, |
| 563 |
'summary' => $on ? __( 'Enabled', 'ablocks' ) : __( 'Not enabled', 'ablocks' ), |
| 564 |
'why' => $on |
| 565 |
? '' |
| 566 |
: __( 'A page is only cached after someone has already waited for it. With background caching on, pages are built ahead of time so the first visitor gets the fast version too.', 'ablocks' ), |
| 567 |
'fix' => $on ? '' : __( 'Turn on "Cache pages in the background" under Performance → Caching.', 'ablocks' ), |
| 568 |
'field' => $on ? '' : 'perf_page_cache_crawler', |
| 569 |
] |
| 570 |
); |
| 571 |
} |
| 572 |
|
| 573 |
// -- Assets -------------------------------------------------------------- |
| 574 |
|
| 575 |
/** |
| 576 |
* Combined per-page asset files. |
| 577 |
*/ |
| 578 |
private static function check_asset_generation() { |
| 579 |
$on = (bool) Helper::get_settings( 'enabled_assets_file_generation', false ); |
| 580 |
|
| 581 |
return self::result( |
| 582 |
[ |
| 583 |
'id' => 'asset_generation', |
| 584 |
'label' => __( 'Combined block CSS and JavaScript', 'ablocks' ), |
| 585 |
'category' => 'assets', |
| 586 |
'status' => $on ? self::GOOD : self::WARNING, |
| 587 |
'summary' => $on ? __( 'Enabled', 'ablocks' ) : __( 'Not enabled', 'ablocks' ), |
| 588 |
'why' => $on |
| 589 |
? '' |
| 590 |
: __( 'Each block otherwise loads its own stylesheet and script, so a page with fifteen blocks makes fifteen extra requests before it can finish rendering.', 'ablocks' ), |
| 591 |
'fix' => $on ? '' : __( 'Turn on "Enable asset file generation" under Performance → Assets. Best done once the site is built.', 'ablocks' ), |
| 592 |
'field' => $on ? '' : 'enabled_assets_file_generation', |
| 593 |
] |
| 594 |
); |
| 595 |
} |
| 596 |
|
| 597 |
/** |
| 598 |
* Deferred JavaScript. |
| 599 |
*/ |
| 600 |
private static function check_defer_js() { |
| 601 |
$on = (bool) Helper::get_settings( 'perf_defer_js', false ); |
| 602 |
|
| 603 |
return self::result( |
| 604 |
[ |
| 605 |
'id' => 'defer_js', |
| 606 |
'label' => __( 'Deferred JavaScript', 'ablocks' ), |
| 607 |
'category' => 'assets', |
| 608 |
'status' => $on ? self::GOOD : self::INFO, |
| 609 |
'summary' => $on ? __( 'Enabled', 'ablocks' ) : __( 'Not enabled', 'ablocks' ), |
| 610 |
'why' => $on ? '' : __( 'Scripts that load in the page head stop the browser drawing anything until they have downloaded and run. Deferring them lets the page appear first.', 'ablocks' ), |
| 611 |
'fix' => $on ? '' : __( 'Turn on "Defer JavaScript" under Performance → Assets.', 'ablocks' ), |
| 612 |
'field' => $on ? '' : 'perf_defer_js', |
| 613 |
] |
| 614 |
); |
| 615 |
} |
| 616 |
|
| 617 |
/** |
| 618 |
* Image lazy loading. |
| 619 |
*/ |
| 620 |
private static function check_lazy_loading() { |
| 621 |
$on = (bool) Helper::get_settings( 'perf_lazy_images', true ); |
| 622 |
|
| 623 |
return self::result( |
| 624 |
[ |
| 625 |
'id' => 'lazy_images', |
| 626 |
'label' => __( 'Lazy-loaded images', 'ablocks' ), |
| 627 |
'category' => 'images', |
| 628 |
'status' => $on ? self::GOOD : self::WARNING, |
| 629 |
'summary' => $on ? __( 'Enabled', 'ablocks' ) : __( 'Not enabled', 'ablocks' ), |
| 630 |
'why' => $on ? '' : __( 'Every image on the page is downloaded immediately, including ones far below the fold that the visitor may never scroll to.', 'ablocks' ), |
| 631 |
'fix' => $on ? '' : __( 'Turn on "Lazy load images" under Performance → Images.', 'ablocks' ), |
| 632 |
'field' => $on ? '' : 'perf_lazy_images', |
| 633 |
] |
| 634 |
); |
| 635 |
} |
| 636 |
|
| 637 |
// -- Images -------------------------------------------------------------- |
| 638 |
|
| 639 |
/** |
| 640 |
* Images not yet compressed. |
| 641 |
*/ |
| 642 |
private static function check_images_unoptimized() { |
| 643 |
$stats = ImageTools::stats(); |
| 644 |
$pending = (int) $stats['pending']; |
| 645 |
$done = (int) $stats['optimized']; |
| 646 |
|
| 647 |
if ( 0 === $pending && 0 === $done ) { |
| 648 |
return []; |
| 649 |
} |
| 650 |
|
| 651 |
return self::result( |
| 652 |
[ |
| 653 |
'id' => 'images_unoptimized', |
| 654 |
'label' => __( 'Image compression', 'ablocks' ), |
| 655 |
'category' => 'images', |
| 656 |
'status' => $pending > 0 ? self::WARNING : self::GOOD, |
| 657 |
'summary' => $pending > 0 |
| 658 |
/* translators: 1: images not optimized, 2: images already optimized. */ |
| 659 |
? sprintf( __( '%1$d image(s) not compressed, %2$d already done', 'ablocks' ), $pending, $done ) |
| 660 |
/* translators: 1: images optimized, 2: space saved. */ |
| 661 |
: sprintf( __( '%1$d image(s) compressed, %2$s saved', 'ablocks' ), $done, $stats['saved'] ), |
| 662 |
'why' => $pending > 0 |
| 663 |
? __( 'Images are usually the largest thing on a page. Compressing them typically removes 40-60% of their weight with no visible difference, and your originals are kept so it can be undone.', 'ablocks' ) |
| 664 |
: '', |
| 665 |
'fix' => $pending > 0 ? __( 'Use "Optimize all images" under Performance → Images.', 'ablocks' ) : '', |
| 666 |
] |
| 667 |
); |
| 668 |
} |
| 669 |
|
| 670 |
/** |
| 671 |
* Images far larger than any layout will display. |
| 672 |
*/ |
| 673 |
private static function check_images_oversized() { |
| 674 |
global $wpdb; |
| 675 |
|
| 676 |
$rows = $wpdb->get_col( |
| 677 |
"SELECT meta_value FROM {$wpdb->postmeta} WHERE meta_key = '_wp_attachment_metadata' LIMIT 500" |
| 678 |
); |
| 679 |
|
| 680 |
$oversized = 0; |
| 681 |
$widest = 0; |
| 682 |
foreach ( (array) $rows as $row ) { |
| 683 |
$meta = maybe_unserialize( $row ); |
| 684 |
if ( ! is_array( $meta ) || empty( $meta['width'] ) ) { |
| 685 |
continue; |
| 686 |
} |
| 687 |
$width = (int) $meta['width']; |
| 688 |
if ( $width > 2560 ) { |
| 689 |
$oversized++; |
| 690 |
$widest = max( $widest, $width ); |
| 691 |
} |
| 692 |
} |
| 693 |
|
| 694 |
if ( 0 === $oversized ) { |
| 695 |
return []; |
| 696 |
} |
| 697 |
|
| 698 |
return self::result( |
| 699 |
[ |
| 700 |
'id' => 'images_oversized', |
| 701 |
'label' => __( 'Oversized images', 'ablocks' ), |
| 702 |
'category' => 'images', |
| 703 |
'status' => self::WARNING, |
| 704 |
/* translators: 1: number of images, 2: widest image in pixels. */ |
| 705 |
'summary' => sprintf( __( '%1$d image(s) wider than 2560px (largest is %2$dpx)', 'ablocks' ), $oversized, $widest ), |
| 706 |
'why' => __( 'A photo straight from a phone or camera can be 5000px wide, while the space it fills on screen is rarely more than 1200px. The visitor downloads every one of those extra pixels and the browser throws them away.', 'ablocks' ), |
| 707 |
'fix' => __( 'Resize large images before uploading, or set a maximum width so WordPress scales them on upload. Compressing them here helps, but resizing helps far more.', 'ablocks' ), |
| 708 |
'url' => admin_url( 'upload.php' ), |
| 709 |
] |
| 710 |
); |
| 711 |
} |
| 712 |
|
| 713 |
/** |
| 714 |
* Images with no alternative text. |
| 715 |
*/ |
| 716 |
private static function check_images_alt_text() { |
| 717 |
global $wpdb; |
| 718 |
|
| 719 |
$missing = (int) $wpdb->get_var( |
| 720 |
"SELECT COUNT(p.ID) FROM {$wpdb->posts} p |
| 721 |
LEFT JOIN {$wpdb->postmeta} m |
| 722 |
ON m.post_id = p.ID AND m.meta_key = '_wp_attachment_image_alt' |
| 723 |
WHERE p.post_type = 'attachment' |
| 724 |
AND p.post_mime_type LIKE 'image/%' |
| 725 |
AND ( m.meta_value IS NULL OR m.meta_value = '' )" |
| 726 |
); |
| 727 |
|
| 728 |
if ( 0 === $missing ) { |
| 729 |
return []; |
| 730 |
} |
| 731 |
|
| 732 |
return self::result( |
| 733 |
[ |
| 734 |
'id' => 'images_alt', |
| 735 |
'label' => __( 'Image alternative text', 'ablocks' ), |
| 736 |
'category' => 'seo', |
| 737 |
'status' => self::WARNING, |
| 738 |
/* translators: %d: number of images. */ |
| 739 |
'summary' => sprintf( __( '%d image(s) have no alt text', 'ablocks' ), $missing ), |
| 740 |
'why' => __( 'Alt text is what screen readers announce and what search engines read to understand a picture. Without it those images are invisible to both, and in some regions missing alt text is an accessibility compliance issue.', 'ablocks' ), |
| 741 |
'fix' => __( 'Open Media → Library in list view and fill in the Alt Text field. Describe what the image shows; leave it empty only for purely decorative images.', 'ablocks' ), |
| 742 |
'url' => admin_url( 'upload.php?mode=list' ), |
| 743 |
] |
| 744 |
); |
| 745 |
} |
| 746 |
|
| 747 |
/** |
| 748 |
* Media that nothing appears to reference. |
| 749 |
*/ |
| 750 |
private static function check_images_unused() { |
| 751 |
$batch = UnusedScanner::scan( 25, 0 ); |
| 752 |
$count = count( $batch['items'] ); |
| 753 |
|
| 754 |
if ( 0 === $count ) { |
| 755 |
return []; |
| 756 |
} |
| 757 |
|
| 758 |
$bytes = 0; |
| 759 |
foreach ( $batch['items'] as $item ) { |
| 760 |
$bytes += (int) $item['bytes']; |
| 761 |
} |
| 762 |
|
| 763 |
return self::result( |
| 764 |
[ |
| 765 |
'id' => 'images_unused', |
| 766 |
'label' => __( 'Possibly unused images', 'ablocks' ), |
| 767 |
'category' => 'images', |
| 768 |
'status' => self::INFO, |
| 769 |
'summary' => sprintf( |
| 770 |
/* translators: 1: number of images found in the sample, 2: disk space. */ |
| 771 |
__( 'At least %1$d image(s) with no reference found, using %2$s', 'ablocks' ), |
| 772 |
$count, |
| 773 |
size_format( $bytes, 1 ) |
| 774 |
), |
| 775 |
'why' => __( 'These take up space and slow down backups. This is a suggestion, not a verdict — an image can be used in ways no scan can see, so nothing is deleted without your review.', 'ablocks' ), |
| 776 |
'fix' => __( 'Use "Scan for unused images" under Performance → Media cleanup. Removal moves files aside so they can be put back.', 'ablocks' ), |
| 777 |
] |
| 778 |
); |
| 779 |
} |
| 780 |
|
| 781 |
// -- Design consistency -------------------------------------------------- |
| 782 |
|
| 783 |
/** |
| 784 |
* Cached content scan, so both design checks parse the site's blocks once. |
| 785 |
* |
| 786 |
* @return array |
| 787 |
*/ |
| 788 |
private static function design_repeats() { |
| 789 |
static $scan = null; |
| 790 |
if ( null === $scan ) { |
| 791 |
$scan = DesignRepeats::scan(); |
| 792 |
} |
| 793 |
return $scan; |
| 794 |
} |
| 795 |
|
| 796 |
/** |
| 797 |
* The same colour typed in repeatedly instead of being a global. |
| 798 |
*/ |
| 799 |
private static function check_repeated_colors() { |
| 800 |
$scan = self::design_repeats(); |
| 801 |
$colors = $scan['colors']; |
| 802 |
|
| 803 |
if ( empty( $colors ) ) { |
| 804 |
return []; |
| 805 |
} |
| 806 |
|
| 807 |
// A colour that already exists as a preset is a different, smaller |
| 808 |
// problem — the value is right, it is just not referenced — so say which |
| 809 |
// is which rather than lumping them together. |
| 810 |
$globals = DesignRepeats::global_colors(); |
| 811 |
$unnamed = []; |
| 812 |
$unlinked = []; |
| 813 |
|
| 814 |
foreach ( $colors as $color ) { |
| 815 |
if ( in_array( $color['value'], $globals, true ) ) { |
| 816 |
$unlinked[] = $color; |
| 817 |
} else { |
| 818 |
$unnamed[] = $color; |
| 819 |
} |
| 820 |
} |
| 821 |
|
| 822 |
$top = array_slice( array_merge( $unnamed, $unlinked ), 0, 5 ); |
| 823 |
$parts = []; |
| 824 |
foreach ( $top as $color ) { |
| 825 |
/* translators: 1: colour value, 2: number of times used. */ |
| 826 |
$parts[] = sprintf( __( '%1$s used %2$d times', 'ablocks' ), $color['value'], $color['count'] ); |
| 827 |
} |
| 828 |
|
| 829 |
return self::result( |
| 830 |
[ |
| 831 |
'id' => 'repeated_colors', |
| 832 |
'label' => __( 'Repeated colours', 'ablocks' ), |
| 833 |
'category' => 'design', |
| 834 |
'status' => self::WARNING, |
| 835 |
'summary' => implode( ', ', $parts ), |
| 836 |
'why' => ! empty( $unnamed ) |
| 837 |
? __( 'These colours are typed directly into blocks rather than referenced from your global palette. They already behave like brand colours — the only difference is that changing one means editing every block by hand, and nothing keeps them consistent as the site grows.', 'ablocks' ) |
| 838 |
: __( 'These colours already exist in your global palette, but the blocks hold a copy of the value instead of pointing at the preset. Updating the preset will not update these blocks.', 'ablocks' ), |
| 839 |
'fix' => ! empty( $unnamed ) |
| 840 |
? __( 'Add each colour to Settings → Editor Options → Global Colors, then select the global colour on those blocks instead of the custom value. Afterwards, turn on the colour lock to stop new ones appearing.', 'ablocks' ) |
| 841 |
: __( 'Re-select the matching global colour on those blocks so they follow the preset.', 'ablocks' ), |
| 842 |
] |
| 843 |
); |
| 844 |
} |
| 845 |
|
| 846 |
/** |
| 847 |
* The same typography settings repeated instead of a global preset. |
| 848 |
*/ |
| 849 |
private static function check_repeated_typography() { |
| 850 |
$scan = self::design_repeats(); |
| 851 |
$fonts = $scan['fonts']; |
| 852 |
|
| 853 |
if ( empty( $fonts ) ) { |
| 854 |
return []; |
| 855 |
} |
| 856 |
|
| 857 |
$parts = []; |
| 858 |
foreach ( array_slice( $fonts, 0, 5 ) as $font ) { |
| 859 |
/* translators: 1: font description, 2: number of times used. */ |
| 860 |
$parts[] = sprintf( __( '"%1$s" used %2$d times', 'ablocks' ), $font['value'], $font['count'] ); |
| 861 |
} |
| 862 |
|
| 863 |
return self::result( |
| 864 |
[ |
| 865 |
'id' => 'repeated_typography', |
| 866 |
'label' => __( 'Repeated typography', 'ablocks' ), |
| 867 |
'category' => 'design', |
| 868 |
'status' => self::WARNING, |
| 869 |
'summary' => implode( ', ', $parts ), |
| 870 |
'why' => __( 'The same typeface, size and weight combination has been set by hand on many blocks. That is a heading style waiting to be named: as it stands, changing your heading size means finding and editing every one of them, and any that get missed drift out of step.', 'ablocks' ), |
| 871 |
'fix' => __( 'Define the combination once under Settings → Editor Options → Global Typography, then apply that preset to the blocks. The design-system lock will keep new content on the presets.', 'ablocks' ), |
| 872 |
] |
| 873 |
); |
| 874 |
} |
| 875 |
|
| 876 |
// -- Fonts --------------------------------------------------------------- |
| 877 |
|
| 878 |
/** |
| 879 |
* How many families and weights the site loads. |
| 880 |
*/ |
| 881 |
private static function check_font_families() { |
| 882 |
$fonts = FontCollector::merge( FontCollector::get_global_fonts(), FontCollector::get_site_fonts() ); |
| 883 |
|
| 884 |
$families = count( (array) $fonts ); |
| 885 |
$weights = 0; |
| 886 |
foreach ( (array) $fonts as $list ) { |
| 887 |
$weights += count( (array) $list ); |
| 888 |
} |
| 889 |
|
| 890 |
if ( 0 === $families ) { |
| 891 |
return []; |
| 892 |
} |
| 893 |
|
| 894 |
// Two families is the working recommendation: one for headings, one for |
| 895 |
// body text. A third is almost always an accident — a block that kept a |
| 896 |
// theme default, or a pattern imported from elsewhere — rather than a |
| 897 |
// design decision anybody made. |
| 898 |
$max_families = (int) apply_filters( 'ablocks/scanner/max_font_families', 2 ); |
| 899 |
$max_weights = (int) apply_filters( 'ablocks/scanner/max_font_weights', 8 ); |
| 900 |
|
| 901 |
$heavy = $families > $max_families || $weights > $max_weights; |
| 902 |
|
| 903 |
return self::result( |
| 904 |
[ |
| 905 |
'id' => 'font_load', |
| 906 |
'label' => __( 'Web fonts in use', 'ablocks' ), |
| 907 |
'category' => 'fonts', |
| 908 |
'status' => $heavy ? self::WARNING : self::GOOD, |
| 909 |
'summary' => $heavy |
| 910 |
? sprintf( |
| 911 |
/* translators: 1: number of font families, 2: number of weights, 3: recommended maximum families. */ |
| 912 |
__( '%1$d font families across %2$d weights — %3$d families is the recommended maximum', 'ablocks' ), |
| 913 |
$families, |
| 914 |
$weights, |
| 915 |
$max_families |
| 916 |
) |
| 917 |
: sprintf( |
| 918 |
/* translators: 1: number of font families, 2: number of weights. */ |
| 919 |
__( '%1$d font family/families across %2$d weight(s)', 'ablocks' ), |
| 920 |
$families, |
| 921 |
$weights |
| 922 |
), |
| 923 |
'why' => $heavy |
| 924 |
? __( 'Every family and every weight is a separate file the browser must download before text can appear in the right typeface. Two families — one for headings, one for body — covers almost every design; a third is usually a block that kept a theme default or a pattern imported from elsewhere, rather than a decision anyone made.', 'ablocks' ) |
| 925 |
: '', |
| 926 |
'fix' => $heavy |
| 927 |
? __( 'Open the Font Usage report under Performance → Fonts to see which pages pull in the extras, then set those blocks to your global typography presets. Turning on the typography lock afterwards stops new ones appearing.', 'ablocks' ) |
| 928 |
: '', |
| 929 |
] |
| 930 |
); |
| 931 |
} |
| 932 |
|
| 933 |
/** |
| 934 |
* Google Fonts served from Google rather than locally. |
| 935 |
*/ |
| 936 |
private static function check_font_local() { |
| 937 |
$fonts = FontCollector::merge( FontCollector::get_global_fonts(), FontCollector::get_site_fonts() ); |
| 938 |
if ( empty( $fonts ) ) { |
| 939 |
return []; |
| 940 |
} |
| 941 |
|
| 942 |
$local = (bool) Helper::get_settings( 'enabled_load_google_font_locally', false ); |
| 943 |
|
| 944 |
return self::result( |
| 945 |
[ |
| 946 |
'id' => 'font_local', |
| 947 |
'label' => __( 'Self-hosted fonts', 'ablocks' ), |
| 948 |
'category' => 'fonts', |
| 949 |
'status' => $local ? self::GOOD : self::WARNING, |
| 950 |
'summary' => $local ? __( 'Fonts are served from this site', 'ablocks' ) : __( 'Fonts are fetched from Google', 'ablocks' ), |
| 951 |
'why' => $local |
| 952 |
? '' |
| 953 |
: __( 'Every visitor makes an extra connection to Google before text can render, and their IP address is shared with a third party — which several European data protection rulings have found unlawful without consent.', 'ablocks' ), |
| 954 |
'fix' => $local ? '' : __( 'Turn on "Load Google Fonts locally" under Performance → Fonts. The files are downloaded once and served from your own server.', 'ablocks' ), |
| 955 |
'field' => $local ? '' : 'enabled_load_google_font_locally', |
| 956 |
] |
| 957 |
); |
| 958 |
} |
| 959 |
|
| 960 |
/** |
| 961 |
* Metric-matched fallback fonts. |
| 962 |
*/ |
| 963 |
private static function check_font_fallback() { |
| 964 |
$fonts = FontCollector::merge( FontCollector::get_global_fonts(), FontCollector::get_site_fonts() ); |
| 965 |
if ( empty( $fonts ) ) { |
| 966 |
return []; |
| 967 |
} |
| 968 |
|
| 969 |
$on = (bool) Helper::get_settings( 'font_metric_fallback', true ); |
| 970 |
|
| 971 |
return self::result( |
| 972 |
[ |
| 973 |
'id' => 'font_fallback', |
| 974 |
'label' => __( 'Zero-shift font fallbacks', 'ablocks' ), |
| 975 |
'category' => 'fonts', |
| 976 |
'status' => $on ? self::GOOD : self::INFO, |
| 977 |
'summary' => $on ? __( 'Enabled', 'ablocks' ) : __( 'Not enabled', 'ablocks' ), |
| 978 |
'why' => $on ? '' : __( 'While a web font loads, text is drawn in a fallback of a different size, so the page visibly jumps when the real font arrives. Google counts that jump against your Core Web Vitals score.', 'ablocks' ), |
| 979 |
'fix' => $on ? '' : __( 'Turn on "Zero-shift font fallbacks" under Performance → Fonts. Nothing extra is downloaded.', 'ablocks' ), |
| 980 |
'field' => $on ? '' : 'font_metric_fallback', |
| 981 |
] |
| 982 |
); |
| 983 |
} |
| 984 |
|
| 985 |
// -- WordPress hygiene --------------------------------------------------- |
| 986 |
|
| 987 |
/** |
| 988 |
* PHP version. |
| 989 |
*/ |
| 990 |
private static function check_php_version() { |
| 991 |
$version = PHP_VERSION; |
| 992 |
$old = version_compare( $version, '8.0', '<' ); |
| 993 |
|
| 994 |
return self::result( |
| 995 |
[ |
| 996 |
'id' => 'php_version', |
| 997 |
'label' => __( 'PHP version', 'ablocks' ), |
| 998 |
'category' => 'server', |
| 999 |
'status' => $old ? self::WARNING : self::GOOD, |
| 1000 |
/* translators: %s: PHP version number. */ |
| 1001 |
'summary' => sprintf( __( 'Running PHP %s', 'ablocks' ), $version ), |
| 1002 |
'why' => $old ? __( 'PHP 8 is roughly twice as fast as PHP 7 for typical WordPress work, and versions below 8.0 no longer receive security fixes.', 'ablocks' ) : '', |
| 1003 |
'fix' => $old ? __( 'Ask your host to move the site to PHP 8.1 or newer. Test on staging first if you run older plugins.', 'ablocks' ) : '', |
| 1004 |
] |
| 1005 |
); |
| 1006 |
} |
| 1007 |
|
| 1008 |
/** |
| 1009 |
* Debug mode left on. |
| 1010 |
*/ |
| 1011 |
private static function check_debug_mode() { |
| 1012 |
$on = defined( 'WP_DEBUG' ) && WP_DEBUG; |
| 1013 |
|
| 1014 |
if ( ! $on ) { |
| 1015 |
return []; |
| 1016 |
} |
| 1017 |
|
| 1018 |
return self::result( |
| 1019 |
[ |
| 1020 |
'id' => 'debug_mode', |
| 1021 |
'label' => __( 'Debug mode', 'ablocks' ), |
| 1022 |
'category' => 'server', |
| 1023 |
'status' => self::WARNING, |
| 1024 |
'summary' => __( 'WP_DEBUG is switched on', 'ablocks' ), |
| 1025 |
'why' => __( 'Debug mode makes WordPress do extra work on every request and can print notices from plugins and themes directly into your pages, occasionally revealing file paths. It is meant for development only.', 'ablocks' ), |
| 1026 |
'fix' => __( 'Set WP_DEBUG to false in wp-config.php on the live site. Leave it on for staging.', 'ablocks' ), |
| 1027 |
] |
| 1028 |
); |
| 1029 |
} |
| 1030 |
|
| 1031 |
/** |
| 1032 |
* Autoloaded options size. |
| 1033 |
*/ |
| 1034 |
private static function check_autoloaded_options() { |
| 1035 |
global $wpdb; |
| 1036 |
|
| 1037 |
$bytes = (int) $wpdb->get_var( |
| 1038 |
"SELECT SUM(LENGTH(option_value)) FROM {$wpdb->options} WHERE autoload IN ('yes','on')" |
| 1039 |
); |
| 1040 |
|
| 1041 |
$heavy = $bytes > 800000; |
| 1042 |
|
| 1043 |
return self::result( |
| 1044 |
[ |
| 1045 |
'id' => 'autoloaded_options', |
| 1046 |
'label' => __( 'Always-loaded settings', 'ablocks' ), |
| 1047 |
'category' => 'server', |
| 1048 |
'status' => $heavy ? self::WARNING : self::GOOD, |
| 1049 |
/* translators: %s: formatted size. */ |
| 1050 |
'summary' => sprintf( __( '%s loaded on every request', 'ablocks' ), size_format( $bytes, 1 ) ), |
| 1051 |
'why' => $heavy |
| 1052 |
? __( 'WordPress loads these settings from the database before it does anything else, on every single page view. Past roughly 800KB this becomes a measurable delay, and it is usually caused by plugins that were removed without cleaning up after themselves.', 'ablocks' ) |
| 1053 |
: '', |
| 1054 |
'fix' => $heavy |
| 1055 |
? __( 'Have a developer review the largest autoloaded rows in wp_options; leftovers from uninstalled plugins can normally be deleted. Take a database backup first.', 'ablocks' ) |
| 1056 |
: '', |
| 1057 |
] |
| 1058 |
); |
| 1059 |
} |
| 1060 |
|
| 1061 |
/** |
| 1062 |
* Permalink structure. |
| 1063 |
*/ |
| 1064 |
private static function check_permalinks() { |
| 1065 |
$structure = (string) get_option( 'permalink_structure' ); |
| 1066 |
$plain = '' === $structure; |
| 1067 |
|
| 1068 |
if ( ! $plain ) { |
| 1069 |
return []; |
| 1070 |
} |
| 1071 |
|
| 1072 |
return self::result( |
| 1073 |
[ |
| 1074 |
'id' => 'permalinks', |
| 1075 |
'label' => __( 'Permalink structure', 'ablocks' ), |
| 1076 |
'category' => 'seo', |
| 1077 |
'status' => self::WARNING, |
| 1078 |
'summary' => __( 'URLs are in the plain ?p=123 format', 'ablocks' ), |
| 1079 |
'why' => __( 'Plain URLs tell neither visitors nor search engines what a page is about, and several caching and SEO features — including sitemap-based cache warming — expect readable URLs.', 'ablocks' ), |
| 1080 |
'fix' => __( 'Settings → Permalinks → choose "Post name". Set up redirects if the site is already live with the old URLs.', 'ablocks' ), |
| 1081 |
'url' => admin_url( 'options-permalink.php' ), |
| 1082 |
] |
| 1083 |
); |
| 1084 |
} |
| 1085 |
|
| 1086 |
/** |
| 1087 |
* Scheduled tasks able to run. |
| 1088 |
*/ |
| 1089 |
private static function check_wp_cron() { |
| 1090 |
$disabled = defined( 'DISABLE_WP_CRON' ) && DISABLE_WP_CRON; |
| 1091 |
|
| 1092 |
if ( ! $disabled ) { |
| 1093 |
$overdue = 0; |
| 1094 |
foreach ( (array) _get_cron_array() as $timestamp => $hooks ) { |
| 1095 |
if ( $timestamp < time() - HOUR_IN_SECONDS ) { |
| 1096 |
$overdue += count( (array) $hooks ); |
| 1097 |
} |
| 1098 |
} |
| 1099 |
|
| 1100 |
if ( $overdue < 3 ) { |
| 1101 |
return self::result( |
| 1102 |
[ |
| 1103 |
'id' => 'wp_cron', |
| 1104 |
'label' => __( 'Scheduled tasks', 'ablocks' ), |
| 1105 |
'category' => 'server', |
| 1106 |
'status' => self::GOOD, |
| 1107 |
'summary' => __( 'Running on time', 'ablocks' ), |
| 1108 |
] |
| 1109 |
); |
| 1110 |
} |
| 1111 |
|
| 1112 |
return self::result( |
| 1113 |
[ |
| 1114 |
'id' => 'wp_cron', |
| 1115 |
'label' => __( 'Scheduled tasks', 'ablocks' ), |
| 1116 |
'category' => 'server', |
| 1117 |
'status' => self::WARNING, |
| 1118 |
/* translators: %d: number of overdue tasks. */ |
| 1119 |
'summary' => sprintf( __( '%d task(s) more than an hour overdue', 'ablocks' ), $overdue ), |
| 1120 |
'why' => __( 'WordPress runs scheduled work when someone visits the site, so a quiet site can fall behind. Cache pruning, background caching, scheduled posts and email all depend on this.', 'ablocks' ), |
| 1121 |
'fix' => __( 'Ask your host to set up a real cron job that calls wp-cron.php every few minutes, then add define( \'DISABLE_WP_CRON\', true ); to wp-config.php.', 'ablocks' ), |
| 1122 |
] |
| 1123 |
); |
| 1124 |
}//end if |
| 1125 |
|
| 1126 |
// Disabled is the *correct* setup when a server cron replaces it, so this |
| 1127 |
// cannot be reported as a fault — only as something to confirm. |
| 1128 |
return self::result( |
| 1129 |
[ |
| 1130 |
'id' => 'wp_cron', |
| 1131 |
'label' => __( 'Scheduled tasks', 'ablocks' ), |
| 1132 |
'category' => 'server', |
| 1133 |
'status' => self::INFO, |
| 1134 |
'summary' => __( 'WordPress cron is switched off', 'ablocks' ), |
| 1135 |
'why' => __( 'This is the recommended setup when a real server cron job calls wp-cron.php instead — but if no such job exists, nothing scheduled will ever run: no cache pruning, no background caching, no scheduled posts.', 'ablocks' ), |
| 1136 |
'fix' => __( 'Confirm with your host that a cron job calls wp-cron.php every few minutes.', 'ablocks' ), |
| 1137 |
] |
| 1138 |
); |
| 1139 |
} |
| 1140 |
|
| 1141 |
/** |
| 1142 |
* A reachable XML sitemap. |
| 1143 |
*/ |
| 1144 |
private static function check_sitemap() { |
| 1145 |
$urls = \ABlocks\Classes\PageCache\Scheduler::sitemap_urls( 5 ); |
| 1146 |
$has = count( $urls ) > 1; |
| 1147 |
|
| 1148 |
return self::result( |
| 1149 |
[ |
| 1150 |
'id' => 'sitemap', |
| 1151 |
'label' => __( 'XML sitemap', 'ablocks' ), |
| 1152 |
'category' => 'seo', |
| 1153 |
'status' => $has ? self::GOOD : self::WARNING, |
| 1154 |
'summary' => $has ? __( 'Found and readable', 'ablocks' ) : __( 'No sitemap could be read', 'ablocks' ), |
| 1155 |
'why' => $has |
| 1156 |
? '' |
| 1157 |
: __( 'Search engines use a sitemap to discover pages they might otherwise miss, and background cache warming reads it to decide what to build. Neither can work without one.', 'ablocks' ), |
| 1158 |
'fix' => $has |
| 1159 |
? '' |
| 1160 |
: __( 'WordPress publishes one at /wp-sitemap.xml unless a plugin or filter has disabled it. Check that the URL loads, or let your SEO plugin generate one.', 'ablocks' ), |
| 1161 |
] |
| 1162 |
); |
| 1163 |
} |
| 1164 |
|
| 1165 |
/** |
| 1166 |
* Site icon. |
| 1167 |
*/ |
| 1168 |
private static function check_site_icon() { |
| 1169 |
if ( has_site_icon() ) { |
| 1170 |
return []; |
| 1171 |
} |
| 1172 |
|
| 1173 |
return self::result( |
| 1174 |
[ |
| 1175 |
'id' => 'site_icon', |
| 1176 |
'label' => __( 'Site icon', 'ablocks' ), |
| 1177 |
'category' => 'seo', |
| 1178 |
'status' => self::INFO, |
| 1179 |
'summary' => __( 'No site icon set', 'ablocks' ), |
| 1180 |
'why' => __( 'The site icon is the small image shown on browser tabs, bookmarks and phone home screens. Without one the site shows a blank page glyph, which looks unfinished next to other tabs.', 'ablocks' ), |
| 1181 |
'fix' => __( 'Appearance → Customize → Site Identity, or Settings → General on newer themes. A square image of at least 512×512 works everywhere.', 'ablocks' ), |
| 1182 |
'url' => admin_url( 'options-general.php' ), |
| 1183 |
] |
| 1184 |
); |
| 1185 |
} |
| 1186 |
|
| 1187 |
/** |
| 1188 |
* Deactivated plugins left installed. |
| 1189 |
*/ |
| 1190 |
private static function check_inactive_plugins() { |
| 1191 |
if ( ! function_exists( 'get_plugins' ) ) { |
| 1192 |
if ( ! is_readable( ABSPATH . 'wp-admin/includes/plugin.php' ) ) { |
| 1193 |
return []; |
| 1194 |
} |
| 1195 |
require_once ABSPATH . 'wp-admin/includes/plugin.php'; |
| 1196 |
} |
| 1197 |
|
| 1198 |
$all = array_keys( (array) get_plugins() ); |
| 1199 |
$active = (array) get_option( 'active_plugins', [] ); |
| 1200 |
$inactive = count( array_diff( $all, $active ) ); |
| 1201 |
|
| 1202 |
if ( $inactive < 5 ) { |
| 1203 |
return []; |
| 1204 |
} |
| 1205 |
|
| 1206 |
return self::result( |
| 1207 |
[ |
| 1208 |
'id' => 'inactive_plugins', |
| 1209 |
'label' => __( 'Deactivated plugins', 'ablocks' ), |
| 1210 |
'category' => 'server', |
| 1211 |
'status' => self::INFO, |
| 1212 |
/* translators: %d: number of inactive plugins. */ |
| 1213 |
'summary' => sprintf( __( '%d plugin(s) installed but not active', 'ablocks' ), $inactive ), |
| 1214 |
'why' => __( 'Inactive plugins do not slow the site down, but their files are still on the server and still need security updates. An abandoned plugin nobody is watching is a common way sites get compromised.', 'ablocks' ), |
| 1215 |
'fix' => __( 'Delete the ones you do not intend to use again. Anything you might need later can be reinstalled in seconds.', 'ablocks' ), |
| 1216 |
'url' => admin_url( 'plugins.php?plugin_status=inactive' ), |
| 1217 |
] |
| 1218 |
); |
| 1219 |
} |
| 1220 |
|
| 1221 |
/** |
| 1222 |
* Theme and plugin file editing from the dashboard. |
| 1223 |
*/ |
| 1224 |
private static function check_file_editing() { |
| 1225 |
$blocked = defined( 'DISALLOW_FILE_EDIT' ) && DISALLOW_FILE_EDIT; |
| 1226 |
|
| 1227 |
return self::result( |
| 1228 |
[ |
| 1229 |
'id' => 'file_editing', |
| 1230 |
'label' => __( 'Dashboard file editor', 'ablocks' ), |
| 1231 |
'category' => 'server', |
| 1232 |
'status' => $blocked ? self::GOOD : self::WARNING, |
| 1233 |
'summary' => $blocked ? __( 'Disabled', 'ablocks' ) : __( 'Theme and plugin files can be edited from the dashboard', 'ablocks' ), |
| 1234 |
'why' => $blocked |
| 1235 |
? '' |
| 1236 |
: __( 'If an administrator account is ever compromised, the built-in editor lets the attacker run any code they like by pasting it into a theme file. It is also an easy way to break a live site with a typo and no undo.', 'ablocks' ), |
| 1237 |
'fix' => $blocked ? '' : __( 'Add define( \'DISALLOW_FILE_EDIT\', true ); to wp-config.php. Edit files over SFTP or through version control instead.', 'ablocks' ), |
| 1238 |
] |
| 1239 |
); |
| 1240 |
} |
| 1241 |
|
| 1242 |
/** |
| 1243 |
* Expired transients left in the options table. |
| 1244 |
*/ |
| 1245 |
private static function check_expired_transients() { |
| 1246 |
global $wpdb; |
| 1247 |
|
| 1248 |
$expired = (int) $wpdb->get_var( |
| 1249 |
$wpdb->prepare( |
| 1250 |
"SELECT COUNT(*) FROM {$wpdb->options} |
| 1251 |
WHERE option_name LIKE %s AND option_value < %d", |
| 1252 |
$wpdb->esc_like( '_transient_timeout_' ) . '%', |
| 1253 |
time() |
| 1254 |
) |
| 1255 |
); |
| 1256 |
|
| 1257 |
if ( $expired < 200 ) { |
| 1258 |
return []; |
| 1259 |
} |
| 1260 |
|
| 1261 |
return self::result( |
| 1262 |
[ |
| 1263 |
'id' => 'expired_transients', |
| 1264 |
'label' => __( 'Expired temporary data', 'ablocks' ), |
| 1265 |
'category' => 'server', |
| 1266 |
'status' => self::INFO, |
| 1267 |
/* translators: %d: number of expired transients. */ |
| 1268 |
'summary' => sprintf( __( '%d expired temporary records still stored', 'ablocks' ), $expired ), |
| 1269 |
'why' => __( 'WordPress only clears these when something asks for them again, so they can accumulate for years. They rarely slow pages down, but they inflate every database backup.', 'ablocks' ), |
| 1270 |
'fix' => __( 'Most database cleanup plugins remove expired transients safely. Take a backup first.', 'ablocks' ), |
| 1271 |
] |
| 1272 |
); |
| 1273 |
} |
| 1274 |
|
| 1275 |
/** |
| 1276 |
* Unbounded post revisions. |
| 1277 |
*/ |
| 1278 |
private static function check_revisions() { |
| 1279 |
global $wpdb; |
| 1280 |
|
| 1281 |
$count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_type = 'revision'" ); |
| 1282 |
|
| 1283 |
if ( $count < 500 ) { |
| 1284 |
return []; |
| 1285 |
} |
| 1286 |
|
| 1287 |
return self::result( |
| 1288 |
[ |
| 1289 |
'id' => 'revisions', |
| 1290 |
'label' => __( 'Stored post revisions', 'ablocks' ), |
| 1291 |
'category' => 'server', |
| 1292 |
'status' => self::INFO, |
| 1293 |
/* translators: %d: number of revisions. */ |
| 1294 |
'summary' => sprintf( __( '%d revisions stored', 'ablocks' ), $count ), |
| 1295 |
'why' => __( 'Every save keeps a full copy of the post. Over years this can become the largest table in the database, which slows backups and some queries. It does not usually slow down page loads.', 'ablocks' ), |
| 1296 |
'fix' => __( 'Add define( \'WP_POST_REVISIONS\', 10 ); to wp-config.php to cap future revisions. Clearing old ones is safe but should follow a backup.', 'ablocks' ), |
| 1297 |
] |
| 1298 |
); |
| 1299 |
} |
| 1300 |
} |
| 1301 |
|