Files
wp-healthcheck/includes/steps/70-performance.php
Steve Hanlon cf3007ec37 Phase 3 v2: all step automations + cross-cutting UI
Step automations (six more):
- Step 3 (Core): current vs latest WP version, db upgrade flag, auto-
  update policy, safe-update sequence reminder.
- Step 5 (Theme): parent/child detection, customisation warning when
  non-default theme without child, theme update available, inactive
  theme list.
- Step 6 (Visual): mShots screenshot URL, key-page HEAD checks (home,
  login, posts page, WC shop/cart/checkout), mixed-content scan.
- Step 7 (Performance): keyless PageSpeed Insights v5 API (mobile +
  desktop, cached 12h, skipped on .local), caching plugin detection,
  heavy-image scan (>500KB).
- Step 10 (Uptime): monitoring plugin detection (ManageWP, MainWP,
  Jetpack, WP Umbrella, UptimeRobot), www/non-www canonical check.
- Step 12 (Wrap-up): cross-step rollup — bad/warn counts, blocked
  steps, top examples for the technician's final glance.

Cross-cutting:
- Sticky step-index sidebar with status dots per step (the linear-
  stepper alternative that keeps the overview).
- 'Stop & escalate' summary card at top listing blocked steps with
  escalation guidance and notes.
- Previous-session snapshot stored on finish; diff banner on the next
  session shows new/resolved/changed counts.
- HTML report builder (printable, inline-styled). Download HTML,
  Download Markdown, Copy, and Email actions on the finish panel.
  Email uses wp_mail with text/html.

Smoke-tested on testsite: all 12 steps return findings (5/9/4 by level
on a fresh local install), admin page renders with all UI markers,
HTML report is 26KB, Markdown report is 13KB, prev-session diff banner
appears on second session.

Deferred:
- hc-5ix.27 self-hosted update channel — needs hosting infra.
- Full PDF report — would need vendoring Dompdf.
- Step 3 safe-mode update wizard — worth its own bead.
2026-06-11 16:13:15 +01:00

132 lines
6.1 KiB
PHP

<?php
if (!defined('ABSPATH')) exit;
return new class extends WPH_Step {
public function id(): string { return 'performance'; }
public function title(): string { return 'Step 7 — Performance Check'; }
public function sub_items(): array {
return [
'Run a quick PageSpeed Insights check on the homepage',
'Note the scores (mobile and desktop) in the client record',
'Flag if mobile score has dropped significantly since last check (more than 10 points)',
'Check that caching is active — if using a caching plugin, confirm it is enabled and not throwing errors',
'Check image sizes on the homepage — flag if uncompressed images over 500KB are being served',
];
}
public function autocheck(array $session_state): array {
$f = [];
$home = home_url('/');
// PageSpeed Insights — no API key needed for low volume (25k/day per IP).
// Skip on localhost: PSI can't reach a local URL.
$host = parse_url($home, PHP_URL_HOST) ?: '';
$is_local = preg_match('/(\.local|localhost|^127\.|^192\.168\.)/', $host);
if ($is_local) {
$f[] = $this->finding('psi_skipped', 'info', 'PageSpeed Insights', 'skipped (local host)', 'Run manually from a public URL: https://pagespeed.web.dev/');
} else {
foreach (['mobile', 'desktop'] as $strategy) {
$psi = $this->psi($home, $strategy);
if ($psi === null) {
$f[] = $this->finding('psi_' . $strategy, 'warn', 'PageSpeed (' . $strategy . ')', 'API error', 'Could not reach googleapis.com — retry or run manually.');
continue;
}
$score = (int) round((float) $psi['performance'] * 100);
$level = $score >= 90 ? 'ok' : ($score >= 50 ? 'warn' : 'bad');
$detail_bits = [];
if (isset($psi['lcp'])) $detail_bits[] = 'LCP ' . $psi['lcp'];
if (isset($psi['cls'])) $detail_bits[] = 'CLS ' . $psi['cls'];
if (isset($psi['tbt'])) $detail_bits[] = 'TBT ' . $psi['tbt'];
$f[] = $this->finding(
'psi_' . $strategy,
$level,
'PageSpeed (' . $strategy . ')',
$score . '/100',
implode(' · ', $detail_bits)
);
}
}
// Caching plugin detection
$caching = [
'wp-rocket/wp-rocket.php' => 'WP Rocket',
'wp-super-cache/wp-cache.php' => 'WP Super Cache',
'w3-total-cache/w3-total-cache.php' => 'W3 Total Cache',
'litespeed-cache/litespeed-cache.php' => 'LiteSpeed Cache',
'sg-cachepress/sg-cachepress.php' => 'SG Optimizer',
'wp-fastest-cache/wpFastestCache.php'=> 'WP Fastest Cache',
'cache-enabler/cache-enabler.php' => 'Cache Enabler',
];
$found_caching = null;
foreach ($caching as $file => $label) {
if (is_plugin_active($file)) { $found_caching = $label; break; }
}
$f[] = $this->finding(
'caching',
$found_caching ? 'ok' : 'info',
'Caching plugin',
$found_caching ?: 'none detected',
$found_caching ? '' : 'No common caching plugin active — host-level caching may still apply.'
);
// Heavy images on the homepage
$resp = wp_remote_get($home, ['timeout' => 6]);
if (!is_wp_error($resp)) {
$body = (string) wp_remote_retrieve_body($resp);
preg_match_all('/<img\b[^>]*src=["\']([^"\']+)["\']/i', $body, $m);
$urls = isset($m[1]) ? array_slice(array_unique($m[1]), 0, 8) : [];
$heavy = [];
foreach ($urls as $u) {
if (substr($u, 0, 2) === '//') $u = (parse_url($home, PHP_URL_SCHEME) ?: 'https') . ':' . $u;
if (substr($u, 0, 1) === '/') $u = rtrim($home, '/') . $u;
$head = wp_remote_head($u, ['timeout' => 3, 'redirection' => 2]);
if (is_wp_error($head)) continue;
$size = (int) wp_remote_retrieve_header($head, 'content-length');
if ($size > 500 * 1024) {
$heavy[] = basename(parse_url($u, PHP_URL_PATH) ?: $u) . ' (' . size_format($size) . ')';
}
}
$f[] = $this->finding(
'heavy_images',
$heavy ? 'warn' : 'ok',
'Large homepage images',
count($heavy) . ' over 500KB',
$heavy ? implode(', ', $heavy) : ''
);
}
return $f;
}
private function psi(string $url, string $strategy): ?array {
$cache_key = 'wph_psi_' . md5($url . '|' . $strategy);
$cached = get_transient($cache_key);
if ($cached !== false) return $cached;
$api = add_query_arg([
'url' => $url,
'strategy' => $strategy,
'category' => 'performance',
], 'https://www.googleapis.com/pagespeedonline/v5/runPagespeed');
$resp = wp_remote_get($api, ['timeout' => 30]);
if (is_wp_error($resp) || wp_remote_retrieve_response_code($resp) !== 200) {
set_transient($cache_key, null, HOUR_IN_SECONDS);
return null;
}
$data = json_decode((string) wp_remote_retrieve_body($resp), true);
if (!is_array($data) || empty($data['lighthouseResult'])) {
set_transient($cache_key, null, HOUR_IN_SECONDS);
return null;
}
$lr = $data['lighthouseResult'];
$out = [
'performance' => $lr['categories']['performance']['score'] ?? null,
'lcp' => $lr['audits']['largest-contentful-paint']['displayValue'] ?? null,
'cls' => $lr['audits']['cumulative-layout-shift']['displayValue'] ?? null,
'tbt' => $lr['audits']['total-blocking-time']['displayValue'] ?? null,
];
set_transient($cache_key, $out, 12 * HOUR_IN_SECONDS);
return $out;
}
};