|
| 1 | +<?php |
| 2 | +/** |
| 3 | + * Helper function to detect if static assets have effective caching headers. |
| 4 | + * |
| 5 | + * @package performance-lab |
| 6 | + * @since n.e.x.t |
| 7 | + */ |
| 8 | + |
| 9 | +// @codeCoverageIgnoreStart |
| 10 | +if ( ! defined( 'ABSPATH' ) ) { |
| 11 | + exit; // Exit if accessed directly. |
| 12 | +} |
| 13 | +// @codeCoverageIgnoreEnd |
| 14 | + |
| 15 | +/** |
| 16 | + * Callback for the effective caching headers test. |
| 17 | + * |
| 18 | + * @since n.e.x.t |
| 19 | + * @access private |
| 20 | + * |
| 21 | + * @return array{label: string, status: string, badge: array{label: string, color: string}, description: string, actions: string, test: string} Result. |
| 22 | + */ |
| 23 | +function perflab_effective_asset_cache_headers_assets_test(): array { |
| 24 | + $result = array( |
| 25 | + 'label' => __( 'Your site serves static assets with an effective caching strategy', 'performance-lab' ), |
| 26 | + 'status' => 'good', |
| 27 | + 'badge' => array( |
| 28 | + 'label' => __( 'Performance', 'performance-lab' ), |
| 29 | + 'color' => 'blue', |
| 30 | + ), |
| 31 | + 'description' => sprintf( |
| 32 | + '<p>%s</p>', |
| 33 | + esc_html__( |
| 34 | + 'Serving static assets with far-future expiration headers improves performance by allowing browsers to cache files for a long time, reducing repeated requests.', |
| 35 | + 'performance-lab' |
| 36 | + ) |
| 37 | + ), |
| 38 | + 'actions' => '', |
| 39 | + 'test' => 'is_effective_asset_cache_headers_enabled', |
| 40 | + ); |
| 41 | + |
| 42 | + // List of assets to check. |
| 43 | + $assets = array( |
| 44 | + includes_url( 'js/wp-embed.min.js' ), |
| 45 | + includes_url( 'css/buttons.min.css' ), |
| 46 | + includes_url( 'fonts/dashicons.woff2' ), |
| 47 | + includes_url( 'images/media/video.png' ), |
| 48 | + ); |
| 49 | + |
| 50 | + /** |
| 51 | + * Filters the list of assets to check for effective caching headers. |
| 52 | + * |
| 53 | + * @since n.e.x.t |
| 54 | + * |
| 55 | + * @param string[] $assets List of asset URLs to check. |
| 56 | + */ |
| 57 | + $assets = apply_filters( 'perflab_effective_asset_cache_headers_assets_to_check', $assets ); |
| 58 | + $assets = array_filter( (array) $assets, 'is_string' ); |
| 59 | + |
| 60 | + // Check if effective caching headers are enabled for all assets. |
| 61 | + $results = perflab_effective_asset_cache_headers_check_assets( $assets ); |
| 62 | + |
| 63 | + if ( 'good' !== $results['final_status'] ) { |
| 64 | + $result['status'] = $results['final_status']; |
| 65 | + $result['label'] = __( 'Your site does not serve static assets with an effective caching strategy', 'performance-lab' ); |
| 66 | + |
| 67 | + if ( count( $results['details'] ) > 0 ) { |
| 68 | + $result['actions'] = sprintf( |
| 69 | + '<p>%s</p>%s<p>%s</p>', |
| 70 | + esc_html__( 'The following file types do not have the recommended effective Cache-Control or Expires headers. Consider adding or adjusting Cache-Control or Expires headers for these asset types.', 'performance-lab' ), |
| 71 | + perflab_effective_asset_cache_headers_get_status_table( $results['details'] ), |
| 72 | + esc_html__( 'Note: "Conditionally cached" means that the browser can re-validate the resource using ETag or Last-Modified headers. This results in fewer full downloads but still requires the browser to make requests, unlike far-future expiration headers that allow the browser to fully rely on its local cache for a longer duration.', 'performance-lab' ) |
| 73 | + ); |
| 74 | + } |
| 75 | + $result['actions'] .= sprintf( |
| 76 | + '<p>%s</p>', |
| 77 | + esc_html__( 'Effective Cache-Control or Expires headers can be added or adjusted with a small configuration change by your hosting provider.', 'performance-lab' ) |
| 78 | + ); |
| 79 | + } |
| 80 | + |
| 81 | + return $result; |
| 82 | +} |
| 83 | + |
| 84 | +/** |
| 85 | + * Checks if effective caching headers are enabled for a list of assets. |
| 86 | + * |
| 87 | + * @since n.e.x.t |
| 88 | + * @access private |
| 89 | + * |
| 90 | + * @param string[] $assets List of asset URLs to check. |
| 91 | + * @return array{final_status: string, details: array{filename: string, reason: string}[]} Final status and details. |
| 92 | + */ |
| 93 | +function perflab_effective_asset_cache_headers_check_assets( array $assets ): array { |
| 94 | + $final_status = 'good'; |
| 95 | + $fail_details = array(); // Array of arrays with 'filename' and 'reason'. |
| 96 | + |
| 97 | + foreach ( $assets as $asset ) { |
| 98 | + $response = wp_remote_get( $asset, array( 'sslverify' => false ) ); |
| 99 | + |
| 100 | + // Extract filename from the URL. |
| 101 | + $path_info = pathinfo( (string) wp_parse_url( $asset, PHP_URL_PATH ) ); |
| 102 | + $filename = $path_info['basename'] ?? basename( $asset ); |
| 103 | + |
| 104 | + if ( is_wp_error( $response ) ) { |
| 105 | + // Can't determine headers if request failed, consider it a fail. |
| 106 | + $final_status = 'recommended'; |
| 107 | + $fail_details[] = array( |
| 108 | + 'filename' => $filename, |
| 109 | + 'reason' => __( 'Could not retrieve headers', 'performance-lab' ), |
| 110 | + ); |
| 111 | + continue; |
| 112 | + } |
| 113 | + |
| 114 | + $headers = wp_remote_retrieve_headers( $response ); |
| 115 | + if ( ! is_object( $headers ) && 0 === count( $headers ) ) { |
| 116 | + // No valid headers retrieved. |
| 117 | + $final_status = 'recommended'; |
| 118 | + $fail_details[] = array( |
| 119 | + 'filename' => $filename, |
| 120 | + 'reason' => __( 'No valid headers retrieved', 'performance-lab' ), |
| 121 | + ); |
| 122 | + continue; |
| 123 | + } |
| 124 | + |
| 125 | + $check = perflab_effective_asset_cache_headers_check_headers( $headers ); |
| 126 | + if ( $check['passed'] ) { |
| 127 | + // This asset passed effective caching headers test, no action needed. |
| 128 | + continue; |
| 129 | + } |
| 130 | + |
| 131 | + // If not passed, decide whether to try conditional request. |
| 132 | + if ( $check['missing_max_age'] ) { |
| 133 | + // Only if no effective caching headers at all, we try conditional request. |
| 134 | + $conditional_pass = perflab_effective_asset_cache_headers_try_conditional_request( $asset, $headers ); |
| 135 | + $final_status = 'recommended'; |
| 136 | + if ( ! $conditional_pass ) { |
| 137 | + $fail_details[] = array( |
| 138 | + 'filename' => $filename, |
| 139 | + 'reason' => __( 'No effective caching headers and no conditional caching', 'performance-lab' ), |
| 140 | + ); |
| 141 | + } else { |
| 142 | + $fail_details[] = array( |
| 143 | + 'filename' => $filename, |
| 144 | + 'reason' => __( 'No effective caching headers but conditionally cached', 'performance-lab' ), |
| 145 | + ); |
| 146 | + } |
| 147 | + } else { |
| 148 | + // If there's a max-age or expires but below threshold, we skip conditional. |
| 149 | + $final_status = 'recommended'; |
| 150 | + $fail_details[] = array( |
| 151 | + 'filename' => $filename, |
| 152 | + 'reason' => $check['reason'], |
| 153 | + ); |
| 154 | + } |
| 155 | + } |
| 156 | + |
| 157 | + return array( |
| 158 | + 'final_status' => $final_status, |
| 159 | + 'details' => $fail_details, |
| 160 | + ); |
| 161 | +} |
| 162 | + |
| 163 | +/** |
| 164 | + * Checks if effective caching headers are enabled. |
| 165 | + * |
| 166 | + * @since n.e.x.t |
| 167 | + * @access private |
| 168 | + * |
| 169 | + * @param WpOrg\Requests\Utility\CaseInsensitiveDictionary|array<string, string|array<string>> $headers Response headers. |
| 170 | + * @return array{passed: bool, reason: string, missing_max_age: bool} Detailed result of the check. |
| 171 | + */ |
| 172 | +function perflab_effective_asset_cache_headers_check_headers( $headers ): array { |
| 173 | + /** |
| 174 | + * Filters the threshold for effective caching headers. |
| 175 | + * |
| 176 | + * @since n.e.x.t |
| 177 | + * |
| 178 | + * @param int $threshold Threshold in seconds. |
| 179 | + */ |
| 180 | + $threshold = apply_filters( 'perflab_effective_asset_cache_headers_expiration_threshold', YEAR_IN_SECONDS ); |
| 181 | + |
| 182 | + $cache_control = $headers['cache-control'] ?? ''; |
| 183 | + $expires = $headers['expires'] ?? ''; |
| 184 | + |
| 185 | + // Check Cache-Control header for max-age. |
| 186 | + $max_age = 0; |
| 187 | + if ( '' !== $cache_control ) { |
| 188 | + // There can be multiple cache-control headers, we only care about max-age. |
| 189 | + foreach ( (array) $cache_control as $control ) { |
| 190 | + if ( 1 === preg_match( '/max-age\s*=\s*(\d+)/', $control, $matches ) ) { |
| 191 | + $max_age = (int) $matches[1]; |
| 192 | + break; |
| 193 | + } |
| 194 | + } |
| 195 | + } |
| 196 | + |
| 197 | + // If max-age meets or exceeds the threshold, we consider it good. |
| 198 | + if ( $max_age >= $threshold ) { |
| 199 | + return array( |
| 200 | + 'passed' => true, |
| 201 | + 'reason' => '', |
| 202 | + 'missing_max_age' => false, |
| 203 | + ); |
| 204 | + } |
| 205 | + |
| 206 | + // If max-age is too low or not present, check Expires. |
| 207 | + if ( is_string( $expires ) && '' !== $expires ) { |
| 208 | + $expires_time = strtotime( $expires ); |
| 209 | + $remaining_time = is_int( $expires_time ) ? $expires_time - time() : 0; |
| 210 | + if ( $remaining_time >= $threshold ) { |
| 211 | + // Good - Expires far in the future. |
| 212 | + return array( |
| 213 | + 'passed' => true, |
| 214 | + 'reason' => '', |
| 215 | + 'missing_max_age' => false, |
| 216 | + ); |
| 217 | + } |
| 218 | + |
| 219 | + // Expires header exists but not far enough in the future. |
| 220 | + if ( $max_age > 0 ) { |
| 221 | + return array( |
| 222 | + 'passed' => false, |
| 223 | + 'reason' => sprintf( |
| 224 | + /* translators: 1: actual max-age value in seconds, 2: threshold in seconds */ |
| 225 | + __( 'max-age below threshold (actual: %1$s seconds, threshold: %2$s seconds)', 'performance-lab' ), |
| 226 | + number_format_i18n( $max_age ), |
| 227 | + number_format_i18n( $threshold ) |
| 228 | + ), |
| 229 | + 'missing_max_age' => false, |
| 230 | + ); |
| 231 | + } |
| 232 | + return array( |
| 233 | + 'passed' => false, |
| 234 | + 'reason' => sprintf( |
| 235 | + /* translators: 1: actual Expires header value in seconds, 2: threshold in seconds */ |
| 236 | + __( 'expires below threshold (actual: %1$s seconds, threshold: %2$s seconds)', 'performance-lab' ), |
| 237 | + number_format_i18n( $remaining_time ), |
| 238 | + number_format_i18n( $threshold ) |
| 239 | + ), |
| 240 | + 'missing_max_age' => false, |
| 241 | + ); |
| 242 | + } |
| 243 | + |
| 244 | + // No max-age or expires found at all or max-age < threshold and no expires. |
| 245 | + if ( 0 === $max_age ) { |
| 246 | + return array( |
| 247 | + 'passed' => false, |
| 248 | + 'reason' => '', |
| 249 | + 'missing_max_age' => true, |
| 250 | + ); |
| 251 | + } else { |
| 252 | + // max-age was present but below threshold and no expires. |
| 253 | + return array( |
| 254 | + 'passed' => false, |
| 255 | + 'reason' => sprintf( |
| 256 | + /* translators: 1: actual max-age value in seconds, 2: threshold in seconds */ |
| 257 | + __( 'max-age below threshold (actual: %1$s seconds, threshold: %2$s seconds)', 'performance-lab' ), |
| 258 | + number_format_i18n( $max_age ), |
| 259 | + number_format_i18n( $threshold ) |
| 260 | + ), |
| 261 | + 'missing_max_age' => false, |
| 262 | + ); |
| 263 | + } |
| 264 | +} |
| 265 | + |
| 266 | +/** |
| 267 | + * Attempt a conditional request with ETag/Last-Modified. |
| 268 | + * |
| 269 | + * @since n.e.x.t |
| 270 | + * @access private |
| 271 | + * |
| 272 | + * @param string $url The asset URL. |
| 273 | + * @param WpOrg\Requests\Utility\CaseInsensitiveDictionary|array<string, string|array<string>> $headers The initial response headers. |
| 274 | + * @return bool True if a 304 response was received. |
| 275 | + */ |
| 276 | +function perflab_effective_asset_cache_headers_try_conditional_request( string $url, $headers ): bool { |
| 277 | + $etag = $headers['etag'] ?? ''; |
| 278 | + $last_modified = $headers['last-modified'] ?? ''; |
| 279 | + |
| 280 | + $conditional_headers = array(); |
| 281 | + if ( '' !== $etag ) { |
| 282 | + $conditional_headers['If-None-Match'] = $etag; |
| 283 | + } |
| 284 | + if ( '' !== $last_modified ) { |
| 285 | + $conditional_headers['If-Modified-Since'] = $last_modified; |
| 286 | + } |
| 287 | + |
| 288 | + $response = wp_remote_get( |
| 289 | + $url, |
| 290 | + array( |
| 291 | + 'sslverify' => false, |
| 292 | + 'headers' => $conditional_headers, |
| 293 | + ) |
| 294 | + ); |
| 295 | + |
| 296 | + if ( is_wp_error( $response ) ) { |
| 297 | + return false; |
| 298 | + } |
| 299 | + |
| 300 | + $status_code = wp_remote_retrieve_response_code( $response ); |
| 301 | + return ( 304 === $status_code ); |
| 302 | +} |
| 303 | + |
| 304 | +/** |
| 305 | + * Generate a table listing files that need effective caching headers, including reasons. |
| 306 | + * |
| 307 | + * @since n.e.x.t |
| 308 | + * @access private |
| 309 | + * |
| 310 | + * @param array<array{filename: string, reason: string}> $fail_details Array of arrays with 'filename' and 'reason'. |
| 311 | + * @return string HTML formatted table. |
| 312 | + */ |
| 313 | +function perflab_effective_asset_cache_headers_get_status_table( array $fail_details ): string { |
| 314 | + $html_table = sprintf( |
| 315 | + '<table class="widefat striped"><thead><tr><th scope="col">%s</th><th scope="col">%s</th></tr></thead><tbody>', |
| 316 | + esc_html__( 'File', 'performance-lab' ), |
| 317 | + esc_html__( 'Status', 'performance-lab' ) |
| 318 | + ); |
| 319 | + |
| 320 | + foreach ( $fail_details as $detail ) { |
| 321 | + $html_table .= sprintf( |
| 322 | + '<tr><td>%s</td><td>%s</td></tr>', |
| 323 | + esc_html( $detail['filename'] ), |
| 324 | + esc_html( $detail['reason'] ) |
| 325 | + ); |
| 326 | + } |
| 327 | + |
| 328 | + $html_table .= '</tbody></table>'; |
| 329 | + |
| 330 | + return $html_table; |
| 331 | +} |
0 commit comments