PluginProbe
aBlocks – Gutenberg Blocks, User Dashboard Builder, Popup Builder, Form Builder & Animation Builder / 2.13.0
aBlocks – Gutenberg Blocks, User Dashboard Builder, Popup Builder, Form Builder & Animation Builder v2.13.0
2.13.0 2.13.1 2.12.0 2.11.1 2.11.0 2.10.0 2.9.0 2.7.4 2.7.5 2.7.6 2.7.7 2.8.0 2.8.1 2.9.1 trunk 1.0 1.0-beta1 1.0-beta2 1.0-beta3 1.0.1 1.0.2 1.0.3 1.1.0 1.1.1 1.1.2 All 80 releases
ablocks / includes / classes / scanner / scanner.php

scanner.php in aBlocks – Gutenberg Blocks, User Dashboard Builder, Popup Builder, Form Builder & Animation Builder 2.13.0, at includes/classes/scanner/scanner.php

1,301 lines 44.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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