| 1 |
<?php |
| 2 |
/** |
| 3 |
* Helper functions for Optimization Detective. |
| 4 |
* |
| 5 |
* @package optimization-detective |
| 6 |
* @since 0.1.0 |
| 7 |
*/ |
| 8 |
|
| 9 |
declare( strict_types = 1 ); |
| 10 |
|
| 11 |
// @codeCoverageIgnoreStart |
| 12 |
if ( ! defined( 'ABSPATH' ) ) { |
| 13 |
exit; // Exit if accessed directly. |
| 14 |
} |
| 15 |
// @codeCoverageIgnoreEnd |
| 16 |
|
| 17 |
/** |
| 18 |
* Initializes extensions for Optimization Detective. |
| 19 |
* |
| 20 |
* @since 0.7.0 |
| 21 |
* @access private |
| 22 |
*/ |
| 23 |
function od_initialize_extensions(): void { |
| 24 |
/** |
| 25 |
* Fires when extensions to Optimization Detective can be loaded and initialized. |
| 26 |
* |
| 27 |
* @since 0.7.0 |
| 28 |
* @link https://github.com/WordPress/performance/blob/trunk/plugins/optimization-detective/docs/hooks.md#:~:text=Action%3A%20od_init |
| 29 |
* |
| 30 |
* @param string $version Optimization Detective version. |
| 31 |
*/ |
| 32 |
do_action( 'od_init', OPTIMIZATION_DETECTIVE_VERSION ); |
| 33 |
} |
| 34 |
|
| 35 |
/** |
| 36 |
* Generates a media query for the provided minimum and maximum viewport widths. |
| 37 |
* |
| 38 |
* This helper function is available for extensions to leverage when manually printing STYLE rules via |
| 39 |
* {@see OD_HTML_Tag_Processor::append_head_html()} or {@see OD_HTML_Tag_Processor::append_body_html()} |
| 40 |
* |
| 41 |
* @since 0.7.0 |
| 42 |
* |
| 43 |
* @param int<0, max>|null $minimum_viewport_width Minimum viewport width (exclusive). |
| 44 |
* @param int<1, max>|null $maximum_viewport_width Maximum viewport width (inclusive). |
| 45 |
* @return non-empty-string|null Media query, or null if the min/max were both unspecified or invalid. |
| 46 |
*/ |
| 47 |
function od_generate_media_query( ?int $minimum_viewport_width, ?int $maximum_viewport_width ): ?string { |
| 48 |
if ( is_int( $minimum_viewport_width ) && is_int( $maximum_viewport_width ) && $minimum_viewport_width >= $maximum_viewport_width ) { |
| 49 |
_doing_it_wrong( __FUNCTION__, esc_html__( 'The minimum width cannot be greater than or equal to the maximum width.', 'optimization-detective' ), 'Optimization Detective 0.7.0' ); |
| 50 |
return null; |
| 51 |
} |
| 52 |
$has_min_width = ( null !== $minimum_viewport_width && $minimum_viewport_width > 0 ); |
| 53 |
$has_max_width = ( null !== $maximum_viewport_width && PHP_INT_MAX !== $maximum_viewport_width ); // Note: The use of PHP_INT_MAX is obsolete. |
| 54 |
if ( $has_min_width && $has_max_width ) { |
| 55 |
return sprintf( '(%dpx < width <= %dpx)', $minimum_viewport_width, $maximum_viewport_width ); |
| 56 |
} elseif ( $has_min_width ) { |
| 57 |
return sprintf( '(%dpx < width)', $minimum_viewport_width ); |
| 58 |
} elseif ( $has_max_width ) { |
| 59 |
return sprintf( '(width <= %dpx)', $maximum_viewport_width ); |
| 60 |
} else { |
| 61 |
return null; |
| 62 |
} |
| 63 |
} |
| 64 |
|
| 65 |
/** |
| 66 |
* Gets the reasons why Optimization Detective is disabled for the current response. |
| 67 |
* |
| 68 |
* @since 1.0.0 |
| 69 |
* @access private |
| 70 |
* |
| 71 |
* @return array{ |
| 72 |
* is_search?: string, |
| 73 |
* is_embed?: string, |
| 74 |
* is_preview?: string, |
| 75 |
* is_customize_preview?: string, |
| 76 |
* non_get_request?: string, |
| 77 |
* no_cache_purge_post_id?: string, |
| 78 |
* filter_disabled?: string, |
| 79 |
* rest_api_unavailable?: string, |
| 80 |
* query_param_disabled?: string |
| 81 |
* } Array of disabled reason codes and their messages. |
| 82 |
*/ |
| 83 |
function od_get_disabled_reasons(): array { |
| 84 |
$disabled_flags = array( |
| 85 |
'is_search' => false, |
| 86 |
'is_embed' => false, |
| 87 |
'is_preview' => false, |
| 88 |
'is_customize_preview' => false, |
| 89 |
'non_get_request' => false, |
| 90 |
'no_cache_purge_post_id' => false, |
| 91 |
); |
| 92 |
|
| 93 |
// Disable the search template since there is no predictability in whether posts in the loop will have featured images assigned or not. If a |
| 94 |
// theme template for search results doesn't even show featured images, then this wouldn't be an issue. |
| 95 |
if ( is_search() ) { |
| 96 |
$disabled_flags['is_search'] = true; |
| 97 |
} |
| 98 |
|
| 99 |
// Avoid optimizing embed responses because the Post Embed iframes include a sandbox attribute with the value of |
| 100 |
// "allow-scripts" but without "allow-same-origin". This can result in an error in the console: |
| 101 |
// > Access to script at '.../detect.js?ver=0.4.1' from origin 'null' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource. |
| 102 |
// So it's better to just avoid attempting to optimize Post Embed responses (which don't need optimization anyway). |
| 103 |
if ( is_embed() ) { |
| 104 |
$disabled_flags['is_embed'] = true; |
| 105 |
} |
| 106 |
|
| 107 |
// Skip posts that aren't published yet. |
| 108 |
if ( is_preview() ) { |
| 109 |
$disabled_flags['is_preview'] = true; |
| 110 |
} |
| 111 |
|
| 112 |
// Disable in Customizer preview since injection of inline-editing controls can interfere with XPath. Optimization is also not necessary in this context. |
| 113 |
if ( is_customize_preview() ) { |
| 114 |
$disabled_flags['is_customize_preview'] = true; |
| 115 |
} |
| 116 |
|
| 117 |
// Disable for POST responses since they cannot, by definition, be cached. |
| 118 |
if ( isset( $_SERVER['REQUEST_METHOD'] ) && 'GET' !== $_SERVER['REQUEST_METHOD'] ) { |
| 119 |
$disabled_flags['non_get_request'] = true; |
| 120 |
} |
| 121 |
|
| 122 |
// Disable when there is no post ID available for cache purging. Page caching plugins can only reliably be told to invalidate a cached page when a post is available to trigger |
| 123 |
// the relevant actions on. |
| 124 |
if ( null === od_get_cache_purge_post_id() ) { |
| 125 |
$disabled_flags['no_cache_purge_post_id'] = true; |
| 126 |
} |
| 127 |
|
| 128 |
// Check if any flags are set to true. |
| 129 |
$has_disabled_flags = count( array_filter( $disabled_flags ) ) > 0; |
| 130 |
|
| 131 |
/** |
| 132 |
* Filters whether the current response can be optimized. |
| 133 |
* |
| 134 |
* @since 0.1.0 |
| 135 |
* @since 1.0.0 Added $disabled_flags parameter |
| 136 |
* @link https://github.com/WordPress/performance/blob/trunk/plugins/optimization-detective/docs/hooks.md#:~:text=Filter%3A%20od_can_optimize_response |
| 137 |
* |
| 138 |
* @param bool $can_optimize Whether response can be optimized. |
| 139 |
* @param array{ |
| 140 |
* is_search: bool, |
| 141 |
* is_embed: bool, |
| 142 |
* is_preview: bool, |
| 143 |
* is_customize_preview: bool, |
| 144 |
* non_get_request: bool, |
| 145 |
* no_cache_purge_post_id: bool |
| 146 |
* } $disabled_flags Flags indicating which conditions are disabling optimization. |
| 147 |
*/ |
| 148 |
$can_optimize = (bool) apply_filters( 'od_can_optimize_response', ! $has_disabled_flags, $disabled_flags ); |
| 149 |
|
| 150 |
$reasons = array(); |
| 151 |
if ( ! $can_optimize ) { |
| 152 |
$reason_messages = array( |
| 153 |
'is_search' => __( 'Page is not optimized because it is a search results page.', 'optimization-detective' ), |
| 154 |
'is_embed' => __( 'Page is not optimized because it is an embed.', 'optimization-detective' ), |
| 155 |
'is_preview' => __( 'Page is not optimized because it is a preview.', 'optimization-detective' ), |
| 156 |
'is_customize_preview' => __( 'Page is not optimized because it is a customize preview.', 'optimization-detective' ), |
| 157 |
'non_get_request' => __( 'Page is not optimized because it is not a GET request.', 'optimization-detective' ), |
| 158 |
'no_cache_purge_post_id' => __( 'Page is not optimized because there is no post ID available for cache purging.', 'optimization-detective' ), |
| 159 |
); |
| 160 |
|
| 161 |
$reasons = array_intersect_key( $reason_messages, array_filter( $disabled_flags ) ); |
| 162 |
|
| 163 |
// If no technical reasons but optimization still disabled, it's because of the filter. |
| 164 |
if ( 0 === count( $reasons ) ) { |
| 165 |
$reasons['filter_disabled'] = __( 'Page is not optimized because the od_can_optimize_response filter returned false.', 'optimization-detective' ); |
| 166 |
} |
| 167 |
} |
| 168 |
|
| 169 |
if ( od_is_rest_api_unavailable() && ! ( wp_get_environment_type() === 'local' && ! function_exists( 'tests_add_filter' ) ) ) { |
| 170 |
$reasons['rest_api_unavailable'] = __( 'Page is not optimized because the REST API for storing URL Metrics is not available.', 'optimization-detective' ); |
| 171 |
} |
| 172 |
|
| 173 |
if ( isset( $_GET['optimization_detective_disabled'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended |
| 174 |
$reasons['query_param_disabled'] = __( 'Page is not optimized because the URL has the optimization_detective_disabled query parameter.', 'optimization-detective' ); |
| 175 |
} |
| 176 |
|
| 177 |
return $reasons; |
| 178 |
} |
| 179 |
|
| 180 |
/** |
| 181 |
* Displays the HTML generator META tag for the Optimization Detective plugin. |
| 182 |
* |
| 183 |
* See {@see 'wp_head'}. |
| 184 |
* |
| 185 |
* @since 0.1.0 |
| 186 |
* @access private |
| 187 |
*/ |
| 188 |
function od_render_generator_meta_tag(): void { |
| 189 |
// Use the plugin slug as it is immutable. |
| 190 |
$content = 'optimization-detective ' . OPTIMIZATION_DETECTIVE_VERSION; |
| 191 |
|
| 192 |
// Add any reasons why Optimization Detective is disabled. |
| 193 |
$disabled_reasons = od_get_disabled_reasons(); |
| 194 |
if ( count( $disabled_reasons ) > 0 ) { |
| 195 |
$flags = array_keys( $disabled_reasons ); |
| 196 |
$content .= '; ' . implode( '; ', $flags ); |
| 197 |
} |
| 198 |
|
| 199 |
echo '<meta name="generator" content="' . esc_attr( $content ) . '">' . "\n"; |
| 200 |
} |
| 201 |
|
| 202 |
/** |
| 203 |
* Adds an Extensions link to the plugin row meta for Optimization Detective. |
| 204 |
* |
| 205 |
* This link directs users to the plugin directory to discover extensions that |
| 206 |
* provide optimization functionality using the Optimization Detective plugin. |
| 207 |
* |
| 208 |
* @since 1.0.0 |
| 209 |
* @access private |
| 210 |
* |
| 211 |
* @param string[]|mixed $plugin_meta The plugin's metadata. |
| 212 |
* @param string $plugin_file Plugin file. |
| 213 |
* @return string[] Updated plugin metadata. |
| 214 |
*/ |
| 215 |
function od_render_extensions_meta_link( $plugin_meta, string $plugin_file ): array { |
| 216 |
if ( ! is_array( $plugin_meta ) ) { |
| 217 |
$plugin_meta = array(); |
| 218 |
} |
| 219 |
if ( 'optimization-detective/load.php' !== $plugin_file || ! current_user_can( 'install_plugins' ) ) { |
| 220 |
return $plugin_meta; |
| 221 |
} |
| 222 |
|
| 223 |
/* @noinspection HtmlUnknownTarget */ |
| 224 |
$extensions_link = sprintf( |
| 225 |
'<a href="%s">%s</a>', |
| 226 |
esc_url( admin_url( 'plugin-install.php?s=optimization-detective&tab=search&type=tag' ) ), |
| 227 |
esc_html__( 'Extensions', 'optimization-detective' ) |
| 228 |
); |
| 229 |
|
| 230 |
$plugin_meta[] = $extensions_link; |
| 231 |
return $plugin_meta; |
| 232 |
} |
| 233 |
|
| 234 |
/** |
| 235 |
* Checks for active extension plugins for Optimization Detective. |
| 236 |
* |
| 237 |
* @since 1.0.0 |
| 238 |
* @access private |
| 239 |
* |
| 240 |
* @return string[] List of active extension plugin files. |
| 241 |
*/ |
| 242 |
function od_get_active_extensions(): array { |
| 243 |
$installed_plugins = get_plugins(); |
| 244 |
$active_extensions = array(); |
| 245 |
|
| 246 |
foreach ( $installed_plugins as $plugin_slug => $plugin_data ) { |
| 247 |
if ( isset( $plugin_data['RequiresPlugins'] ) && is_string( $plugin_data['RequiresPlugins'] ) ) { |
| 248 |
$required_plugins = array_map( 'trim', explode( ',', $plugin_data['RequiresPlugins'] ) ); |
| 249 |
if ( in_array( 'optimization-detective', $required_plugins, true ) && is_plugin_active( $plugin_slug ) ) { |
| 250 |
$active_extensions[] = $plugin_slug; |
| 251 |
} |
| 252 |
} |
| 253 |
} |
| 254 |
|
| 255 |
// Check for plugins without Requires Plugins header but known to be extensions. |
| 256 |
$suggesting_extensions = array( |
| 257 |
'embed-optimizer/load.php', |
| 258 |
); |
| 259 |
foreach ( $suggesting_extensions as $extension ) { |
| 260 |
if ( isset( $installed_plugins[ $extension ] ) && is_plugin_active( $extension ) ) { |
| 261 |
$active_extensions[] = $extension; |
| 262 |
} |
| 263 |
} |
| 264 |
|
| 265 |
return array_values( array_unique( $active_extensions ) ); |
| 266 |
} |
| 267 |
|
| 268 |
/** |
| 269 |
* Renders an inline admin notice prompting the user to install or activate extensions for Optimization Detective. |
| 270 |
* |
| 271 |
* @since 1.0.0 |
| 272 |
* @access private |
| 273 |
*/ |
| 274 |
function od_maybe_render_installed_extensions_admin_notice(): void { |
| 275 |
if ( ! current_user_can( 'activate_plugins' ) ) { |
| 276 |
return; |
| 277 |
} |
| 278 |
$active_extensions = od_get_active_extensions(); |
| 279 |
if ( count( $active_extensions ) > 0 ) { |
| 280 |
return; |
| 281 |
} |
| 282 |
|
| 283 |
$message = sprintf( |
| 284 |
'<summary style="margin: 0.5em 0">%s</summary>', |
| 285 |
esc_html__( 'Optimization Detective is a framework plugin which requires extensions.', 'optimization-detective' ) |
| 286 |
); |
| 287 |
|
| 288 |
$message .= '<p>' . esc_html__( 'This plugin doesn’t provide standalone functionality; it is a framework that requires extension plugins to implement optimizations. Please install and activate one or more of the following extensions:', 'optimization-detective' ) . '</p>'; |
| 289 |
|
| 290 |
$featured_extensions = array( |
| 291 |
'image-prioritizer' => array( |
| 292 |
'name' => __( 'Image Prioritizer', 'optimization-detective' ), |
| 293 |
'description' => __( 'Prioritizes the loading of images and videos based on how visible they are to actual visitors; adds fetchpriority and applies lazy-loading.', 'optimization-detective' ), |
| 294 |
'url' => admin_url( 'plugin-install.php?tab=plugin-information&plugin=image-prioritizer&TB_iframe=true&width=772' ), |
| 295 |
), |
| 296 |
'embed-optimizer' => array( |
| 297 |
'name' => __( 'Embed Optimizer', 'optimization-detective' ), |
| 298 |
'description' => __( 'Optimizes the performance of embeds through lazy-loading, adding dns-prefetch links, and reserving space to reduce layout shifts.', 'optimization-detective' ), |
| 299 |
'url' => admin_url( 'plugin-install.php?tab=plugin-information&plugin=embed-optimizer&TB_iframe=true&width=772' ), |
| 300 |
), |
| 301 |
); |
| 302 |
|
| 303 |
$message .= '<table class="widefat" style="margin-bottom: 11px;"><tbody>'; |
| 304 |
foreach ( $featured_extensions as $featured_extension ) { |
| 305 |
/* @noinspection HtmlUnknownTarget */ |
| 306 |
$message .= sprintf( |
| 307 |
'<tr> |
| 308 |
<td><strong>%s</strong></td> |
| 309 |
<td>%s</td> |
| 310 |
</tr>', |
| 311 |
current_user_can( 'install_plugins' ) ? |
| 312 |
sprintf( '<a href="%s" class="thickbox open-plugin-details-modal">%s</a>', esc_url( $featured_extension['url'] ), esc_html( $featured_extension['name'] ) ) : |
| 313 |
esc_html( $featured_extension['name'] ), |
| 314 |
esc_html( $featured_extension['description'] ) |
| 315 |
); |
| 316 |
} |
| 317 |
$message .= '</tbody></table>'; |
| 318 |
$message = "<details>$message</details>"; |
| 319 |
|
| 320 |
$notice = wp_get_admin_notice( |
| 321 |
$message, |
| 322 |
array( |
| 323 |
'type' => 'info', |
| 324 |
'additional_classes' => array( 'inline' ), |
| 325 |
'paragraph_wrap' => false, |
| 326 |
) |
| 327 |
); |
| 328 |
|
| 329 |
if ( current_user_can( 'install_plugins' ) ) { |
| 330 |
add_thickbox(); |
| 331 |
} |
| 332 |
echo wp_kses( $notice, wp_kses_allowed_html( 'post' ) ); |
| 333 |
} |
| 334 |
|
| 335 |
/** |
| 336 |
* Renders a paragraph of links to the plugin's documentation on GitHub. |
| 337 |
* |
| 338 |
* @since 1.0.0 |
| 339 |
* @access private |
| 340 |
*/ |
| 341 |
function od_render_documentation_links(): void { |
| 342 |
echo '<p>'; |
| 343 |
/* @noinspection HtmlUnknownTarget */ |
| 344 |
echo wp_kses_post( |
| 345 |
sprintf( |
| 346 |
/* translators: 1: project documentation URL, 2: introduction URL, 3: code reference URL, 4: extensions list URL. */ |
| 347 |
__( 'The <a href="%1$s" target="_blank">project documentation</a> is available on GitHub, including an <a href="%2$s" target="_blank">introduction</a>, <a href="%3$s" target="_blank">code reference</a>, and a list of <a href="%4$s" target="_blank">extensions</a>.', 'optimization-detective' ), |
| 348 |
esc_url( 'https://github.com/WordPress/performance/tree/trunk/plugins/optimization-detective/docs' ), |
| 349 |
esc_url( 'https://github.com/WordPress/performance/blob/trunk/plugins/optimization-detective/docs/introduction.md' ), |
| 350 |
esc_url( 'https://github.com/WordPress/performance/blob/trunk/plugins/optimization-detective/docs/hooks.md' ), |
| 351 |
esc_url( 'https://github.com/WordPress/performance/blob/trunk/plugins/optimization-detective/docs/extensions.md' ) |
| 352 |
) |
| 353 |
); |
| 354 |
echo '</p>'; |
| 355 |
} |
| 356 |
|
| 357 |
/** |
| 358 |
* Displays an inline admin notice on the plugin row if no extensions are installed and active. |
| 359 |
* |
| 360 |
* @since 1.0.0 |
| 361 |
* @access private |
| 362 |
* |
| 363 |
* @param non-empty-string $plugin_file Plugin file. |
| 364 |
*/ |
| 365 |
function od_render_installed_extensions_admin_notice_in_plugin_row( string $plugin_file ): void { |
| 366 |
if ( 'optimization-detective/load.php' !== $plugin_file ) { |
| 367 |
return; |
| 368 |
} |
| 369 |
od_maybe_render_installed_extensions_admin_notice(); |
| 370 |
od_render_documentation_links(); |
| 371 |
} |
| 372 |
|
| 373 |
/** |
| 374 |
* Gets the path to a script or stylesheet. |
| 375 |
* |
| 376 |
* @since 0.9.0 |
| 377 |
* @access private |
| 378 |
* |
| 379 |
* @param string $src_path Source path, relative to the plugin root. |
| 380 |
* @param string|null $min_path Minified path. If not supplied, then '.min' is injected before the file extension in the source path. |
| 381 |
* @return string URL to script or stylesheet. |
| 382 |
* |
| 383 |
* @noinspection PhpDocMissingThrowsInspection |
| 384 |
*/ |
| 385 |
function od_get_asset_path( string $src_path, ?string $min_path = null ): string { |
| 386 |
if ( null === $min_path ) { |
| 387 |
// Note: wp_scripts_get_suffix() is not used here because we need access to both the source and minified paths. |
| 388 |
$min_path = (string) preg_replace( '/(?=\.\w+$)/', '.min', $src_path ); |
| 389 |
} |
| 390 |
|
| 391 |
$force_src = false; |
| 392 |
if ( WP_DEBUG && ! file_exists( trailingslashit( __DIR__ ) . $min_path ) ) { |
| 393 |
$force_src = true; |
| 394 |
/** |
| 395 |
* No WP_Exception is thrown by wp_trigger_error() since E_USER_ERROR is not passed as the error level. |
| 396 |
* |
| 397 |
* @noinspection PhpUnhandledExceptionInspection |
| 398 |
*/ |
| 399 |
wp_trigger_error( |
| 400 |
__FUNCTION__, |
| 401 |
sprintf( |
| 402 |
/* translators: %s is the minified asset path */ |
| 403 |
__( 'Minified asset has not been built: %s', 'optimization-detective' ), |
| 404 |
$min_path |
| 405 |
), |
| 406 |
E_USER_WARNING |
| 407 |
); |
| 408 |
} |
| 409 |
|
| 410 |
if ( SCRIPT_DEBUG || $force_src ) { |
| 411 |
return $src_path; |
| 412 |
} |
| 413 |
|
| 414 |
return $min_path; |
| 415 |
} |
| 416 |
|