Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
2.53% |
2 / 79 |
|
16.67% |
2 / 12 |
CRAP | |
0.00% |
0 / 1 |
| Render_Blocking_JS | |
2.53% |
2 / 79 |
|
16.67% |
2 / 12 |
1375.08 | |
0.00% |
0 / 1 |
| setup | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
2 | |||
| is_available | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| start_output_filtering | |
0.00% |
0 / 26 |
|
0.00% |
0 / 1 |
462 | |||
| handle_output_stream | |
0.00% |
0 / 12 |
|
0.00% |
0 / 1 |
20 | |||
| get_script_tags | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
6 | |||
| ignore_exclusion_scripts | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
2 | |||
| recalculate_buffer_split | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
2 | |||
| append_script_tags | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
6 | |||
| handle_exclusions | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
6 | |||
| add_ignore_attribute | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| is_opened_script | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
2 | |||
| get_slug | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| 1 | <?php |
| 2 | /** |
| 3 | * Implements the system to avoid render blocking JS execution. |
| 4 | * |
| 5 | * @link https://automattic.com |
| 6 | * @since 0.2 |
| 7 | * @package automattic/jetpack-boost |
| 8 | */ |
| 9 | |
| 10 | namespace Automattic\Jetpack_Boost\Modules\Optimizations\Render_Blocking_JS; |
| 11 | |
| 12 | use Automattic\Jetpack_Boost\Contracts\Changes_Output_On_Activation; |
| 13 | use Automattic\Jetpack_Boost\Contracts\Feature; |
| 14 | use Automattic\Jetpack_Boost\Contracts\Optimization; |
| 15 | use Automattic\Jetpack_Boost\Lib\Output_Filter; |
| 16 | |
| 17 | /** |
| 18 | * Class Render_Blocking_JS |
| 19 | */ |
| 20 | class Render_Blocking_JS implements Feature, Changes_Output_On_Activation, Optimization { |
| 21 | /** |
| 22 | * Holds the script tags removed from the output buffer. |
| 23 | * |
| 24 | * @var array |
| 25 | */ |
| 26 | protected $buffered_script_tags = array(); |
| 27 | |
| 28 | /** |
| 29 | * HTML attribute name to be added to <script> tag to make it |
| 30 | * ignored by this class. |
| 31 | * |
| 32 | * @var string|null |
| 33 | */ |
| 34 | private $ignore_attribute; |
| 35 | |
| 36 | /** |
| 37 | * HTML attribute value to be added to <script> tag to make it |
| 38 | * ignored by this class. |
| 39 | * |
| 40 | * @var string |
| 41 | */ |
| 42 | private $ignore_value = 'ignore'; |
| 43 | |
| 44 | /** |
| 45 | * Utility class that supports output filtering. |
| 46 | * |
| 47 | * @var Output_Filter |
| 48 | */ |
| 49 | private $output_filter = null; |
| 50 | |
| 51 | /** |
| 52 | * Flag indicating an opened <script> tag in output. |
| 53 | * |
| 54 | * @var string |
| 55 | */ |
| 56 | private $is_opened_script = false; |
| 57 | |
| 58 | public function setup() { |
| 59 | $this->output_filter = new Output_Filter(); |
| 60 | |
| 61 | /** |
| 62 | * Filters the ignore attribute |
| 63 | * |
| 64 | * @param $string $ignore_attribute The string used to ignore elements of the page. |
| 65 | * |
| 66 | * @since 1.0.0 |
| 67 | */ |
| 68 | $this->ignore_attribute = apply_filters( 'jetpack_boost_render_blocking_js_ignore_attribute', 'data-jetpack-boost' ); |
| 69 | |
| 70 | add_action( 'template_redirect', array( $this, 'start_output_filtering' ), -999999 ); |
| 71 | |
| 72 | /** |
| 73 | * Shortcodes can sometimes output script to embed widget. It's safer to ignore them. |
| 74 | */ |
| 75 | add_filter( 'do_shortcode_tag', array( $this, 'add_ignore_attribute' ) ); |
| 76 | } |
| 77 | |
| 78 | public static function is_available() { |
| 79 | return true; |
| 80 | } |
| 81 | |
| 82 | /** |
| 83 | * Set up an output filtering callback. |
| 84 | * |
| 85 | * @return void |
| 86 | */ |
| 87 | public function start_output_filtering() { |
| 88 | /** |
| 89 | * We're doing heavy output filtering in this module |
| 90 | * by using output buffering. |
| 91 | * |
| 92 | * Here are a few scenarios when we shouldn't do it: |
| 93 | */ |
| 94 | |
| 95 | /** |
| 96 | * Filter to disable defer blocking JS |
| 97 | * |
| 98 | * @param bool $defer return false to disable defer blocking |
| 99 | * |
| 100 | * @since 1.0.0 |
| 101 | */ |
| 102 | if ( false === apply_filters( 'jetpack_boost_should_defer_js', '__return_true' ) ) { |
| 103 | return; |
| 104 | } |
| 105 | |
| 106 | // Disable in robots.txt. |
| 107 | if ( isset( $_SERVER['REQUEST_URI'] ) && strpos( home_url( wp_unslash( $_SERVER['REQUEST_URI'] ) ), 'robots.txt' ) !== false ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- This is validating. |
| 108 | return; |
| 109 | } |
| 110 | |
| 111 | // Disable in other possible AJAX requests setting cors related header. |
| 112 | if ( isset( $_SERVER['HTTP_SEC_FETCH_MODE'] ) && 'cors' === strtolower( $_SERVER['HTTP_SEC_FETCH_MODE'] ) ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- This is validating. |
| 113 | return; |
| 114 | } |
| 115 | |
| 116 | // Disable in other possible AJAX requests setting XHR related header. |
| 117 | if ( isset( $_SERVER['HTTP_X_REQUESTED_WITH'] ) && 'xmlhttprequest' === strtolower( $_SERVER['HTTP_X_REQUESTED_WITH'] ) ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- This is validating. |
| 118 | return; |
| 119 | } |
| 120 | |
| 121 | // Disable in all XLS (see the WP_Sitemaps_Renderer class which is responsible for rendering Sitemaps data to XML |
| 122 | // in accordance with sitemap protocol). |
| 123 | if ( isset( $_SERVER['REQUEST_URI'] ) && |
| 124 | ( |
| 125 | // phpcs:disable WordPress.Security.ValidatedSanitizedInput -- This is validating. |
| 126 | str_contains( $_SERVER['REQUEST_URI'], '.xsl' ) || |
| 127 | str_contains( $_SERVER['REQUEST_URI'], 'sitemap-stylesheet=index' ) || |
| 128 | str_contains( $_SERVER['REQUEST_URI'], 'sitemap-stylesheet=sitemap' ) |
| 129 | // phpcs:enable WordPress.Security.ValidatedSanitizedInput |
| 130 | ) ) { |
| 131 | return; |
| 132 | } |
| 133 | |
| 134 | // Disable in all POST Requests. |
| 135 | // phpcs:disable WordPress.Security.NonceVerification.Missing |
| 136 | if ( ! empty( $_POST ) ) { |
| 137 | return; |
| 138 | } |
| 139 | |
| 140 | // Disable in customizer previews |
| 141 | if ( is_customize_preview() ) { |
| 142 | return; |
| 143 | } |
| 144 | |
| 145 | // Disable in feeds, AJAX, Cron, XML. |
| 146 | if ( is_feed() || wp_doing_ajax() || wp_doing_cron() || wp_is_xml_request() ) { |
| 147 | return; |
| 148 | } |
| 149 | |
| 150 | // Disable in sitemaps. |
| 151 | if ( ! empty( get_query_var( 'sitemap' ) ) ) { |
| 152 | return; |
| 153 | } |
| 154 | |
| 155 | // Disable in AMP pages. |
| 156 | if ( function_exists( 'amp_is_request' ) && amp_is_request() ) { |
| 157 | return; |
| 158 | } |
| 159 | |
| 160 | // Print the filtered script tags to the very end of the page. |
| 161 | add_filter( 'jetpack_boost_output_filtering_last_buffer', array( $this, 'append_script_tags' ), 10, 1 ); |
| 162 | |
| 163 | // Handle exclusions. |
| 164 | add_filter( 'script_loader_tag', array( $this, 'handle_exclusions' ), 10, 2 ); |
| 165 | |
| 166 | $this->output_filter->add_callback( array( $this, 'handle_output_stream' ) ); |
| 167 | } |
| 168 | |
| 169 | /** |
| 170 | * Remove all inline and external <script> tags from the default output. |
| 171 | * |
| 172 | * @param string $buffer_start First part of the buffer. |
| 173 | * @param string $buffer_end Second part of the buffer. |
| 174 | * |
| 175 | * For explanation on why there are two parts of a buffer here, see |
| 176 | * the comments and examples in the Output_Filter class. |
| 177 | * |
| 178 | * @return array Parts of the buffer. |
| 179 | */ |
| 180 | public function handle_output_stream( $buffer_start, $buffer_end ) { |
| 181 | $joint_buffer = $this->ignore_exclusion_scripts( $buffer_start . $buffer_end ); |
| 182 | $script_tags = $this->get_script_tags( $joint_buffer ); |
| 183 | |
| 184 | if ( ! $script_tags ) { |
| 185 | if ( $this->is_opened_script ) { |
| 186 | // We have an opened script tag, move everything to the second buffer to avoid printing it to the page. |
| 187 | // We will do this until the </script> closing tag is encountered. |
| 188 | return array( '', $joint_buffer ); |
| 189 | } |
| 190 | |
| 191 | // No script tags detected, return both chunks unaltered. |
| 192 | return array( $buffer_start, $buffer_end ); |
| 193 | } |
| 194 | |
| 195 | // Makes sure all whole <script>...</script> tags are in $buffer_start. |
| 196 | list( $buffer_start, $buffer_end ) = $this->recalculate_buffer_split( $joint_buffer, $script_tags ); |
| 197 | |
| 198 | foreach ( $script_tags as $script_tag ) { |
| 199 | $this->buffered_script_tags[] = $script_tag[0]; |
| 200 | $buffer_start = str_replace( $script_tag[0], '', $buffer_start ); |
| 201 | } |
| 202 | |
| 203 | // Detect a lingering opened script. |
| 204 | $this->is_opened_script = $this->is_opened_script( $buffer_start . $buffer_end ); |
| 205 | |
| 206 | return array( $buffer_start, $buffer_end ); |
| 207 | } |
| 208 | |
| 209 | /** |
| 210 | * Matches <script> tags with their content in a string buffer. |
| 211 | * |
| 212 | * @param string $buffer Captured piece of output buffer. |
| 213 | * |
| 214 | * @return array |
| 215 | */ |
| 216 | protected function get_script_tags( $buffer ) { |
| 217 | $regex = sprintf( '~<script(?![^>]*%s=(?<q>["\']*)%s\k<q>)([^>]*)>[\s\S]*?<\/script>~si', preg_quote( $this->ignore_attribute, '~' ), preg_quote( $this->ignore_value, '~' ) ); |
| 218 | preg_match_all( $regex, $buffer, $script_tags, PREG_OFFSET_CAPTURE ); |
| 219 | |
| 220 | // No script_tags in the joint buffer. |
| 221 | if ( empty( $script_tags[0] ) ) { |
| 222 | return array(); |
| 223 | } |
| 224 | |
| 225 | /** |
| 226 | * Filter to remove any scripts that should not be moved to the end of the document. |
| 227 | * |
| 228 | * @param array $script_tags array of script tags. Remove any scripts that should not be moved to the end of the documents. |
| 229 | * |
| 230 | * @since 1.0.0 |
| 231 | */ |
| 232 | return apply_filters( 'jetpack_boost_render_blocking_js_exclude_scripts', $script_tags[0] ); |
| 233 | } |
| 234 | |
| 235 | /** |
| 236 | * Adds the ignore attribute to scripts in the exclusion list. |
| 237 | * |
| 238 | * @param string $buffer Captured piece of output buffer. |
| 239 | * |
| 240 | * @return string |
| 241 | */ |
| 242 | protected function ignore_exclusion_scripts( $buffer ) { |
| 243 | $exclusions = array( |
| 244 | // Scripts inside HTML comments. |
| 245 | '~<!--.*?-->~si', |
| 246 | |
| 247 | // Scripts with types that do not execute complex code. Moving them down can be dangerous |
| 248 | // and does not benefit performance. Includes types: application/json, application/ld+json and importmap. |
| 249 | '~<script\s+[^\>]*type=(?<q>["\']*)(application\/(ld\+)?json|importmap)\k<q>.*?>.*?<\/script>~si', |
| 250 | ); |
| 251 | |
| 252 | return preg_replace_callback( |
| 253 | $exclusions, |
| 254 | function ( $script_match ) { |
| 255 | return $this->add_ignore_attribute( $script_match[0] ); |
| 256 | }, |
| 257 | $buffer |
| 258 | ); |
| 259 | } |
| 260 | |
| 261 | /** |
| 262 | * Splits the buffer into two parts. |
| 263 | * |
| 264 | * First part contains all whole <script> tags, the second part |
| 265 | * contains the rest of the buffer. |
| 266 | * |
| 267 | * @param string $buffer Captured piece of output buffer. |
| 268 | * @param array $script_tags Matched <script> tags. |
| 269 | * |
| 270 | * @return array |
| 271 | */ |
| 272 | protected function recalculate_buffer_split( $buffer, $script_tags ) { |
| 273 | $last_script_tag_index = count( $script_tags ) - 1; |
| 274 | $last_script_tag_end_position = strrpos( $buffer, $script_tags[ $last_script_tag_index ][0] ) + strlen( $script_tags[ $last_script_tag_index ][0] ); |
| 275 | |
| 276 | // Bundle all script tags into the first buffer. |
| 277 | $buffer_start = substr( $buffer, 0, $last_script_tag_end_position ); |
| 278 | |
| 279 | // Leave the rest of the data in the second buffer. |
| 280 | $buffer_end = substr( $buffer, $last_script_tag_end_position ); |
| 281 | |
| 282 | return array( $buffer_start, $buffer_end ); |
| 283 | } |
| 284 | |
| 285 | /** |
| 286 | * Insert the buffered script tags just before the body tag if possible in the last buffer |
| 287 | * otherwise at append it at the end. |
| 288 | * |
| 289 | * @param string $buffer String buffer. |
| 290 | * |
| 291 | * @return string |
| 292 | */ |
| 293 | public function append_script_tags( $buffer ) { |
| 294 | $script_tags = implode( '', $this->buffered_script_tags ); |
| 295 | // Reset tags in case there's another buffer after this one. |
| 296 | $this->buffered_script_tags = array(); |
| 297 | |
| 298 | if ( str_contains( $buffer, '</body>' ) ) { |
| 299 | $buffer = str_replace( '</body>', $script_tags . '</body>', $buffer ); |
| 300 | } else { |
| 301 | $buffer .= $script_tags; |
| 302 | } |
| 303 | |
| 304 | return $buffer; |
| 305 | } |
| 306 | |
| 307 | /** |
| 308 | * Exclude certain scripts from being processed by this class. |
| 309 | * |
| 310 | * @param string $tag <script> opening tag. |
| 311 | * @param string $handle Script handle from register_ or enqueue_ methods. |
| 312 | * |
| 313 | * @return string |
| 314 | */ |
| 315 | public function handle_exclusions( $tag, $handle ) { |
| 316 | /** |
| 317 | * Filter to provide an array of registered script handles that should not be moved to the end of the document. |
| 318 | * |
| 319 | * @param array $script_handles array of script handles. Remove any scripts that should not be moved to the end of the documents. |
| 320 | * |
| 321 | * @since 1.0.0 |
| 322 | */ |
| 323 | $exclude_handles = apply_filters( 'jetpack_boost_render_blocking_js_exclude_handles', array() ); |
| 324 | |
| 325 | if ( ! in_array( $handle, $exclude_handles, true ) ) { |
| 326 | return $tag; |
| 327 | } |
| 328 | |
| 329 | return $this->add_ignore_attribute( $tag ); |
| 330 | } |
| 331 | |
| 332 | /** |
| 333 | * Add the ignore attribute to the script tags |
| 334 | * |
| 335 | * @param string $html HTML code possibly containing a <script> opening tag. |
| 336 | * |
| 337 | * @return string |
| 338 | */ |
| 339 | public function add_ignore_attribute( $html ) { |
| 340 | return str_replace( '<script', sprintf( '<script %s="%s"', esc_html( $this->ignore_attribute ), esc_attr( $this->ignore_value ) ), $html ); |
| 341 | } |
| 342 | |
| 343 | /** |
| 344 | * Detects an unclosed script tag in a buffer. |
| 345 | * |
| 346 | * @param string $buffer Joint buffer. |
| 347 | * |
| 348 | * @return bool |
| 349 | */ |
| 350 | public function is_opened_script( $buffer ) { |
| 351 | $opening_tags_count = preg_match_all( '~<\s*script(?![^>]*%s="%s")([^>]*)>~', $buffer ); |
| 352 | $closing_tags_count = preg_match_all( '~<\s*/script[^>]*>~', $buffer ); |
| 353 | |
| 354 | /** |
| 355 | * This works, because the logic in `handle_output_stream` will never |
| 356 | * allow an unpaired closing </script> tag to appear in the buffer. |
| 357 | * |
| 358 | * Open script tags are always kept in the buffer until their closing |
| 359 | * tags eventually arrive as well. That means it's only possible to |
| 360 | * encounter an unpaired opening <script> in a buffer, which is why |
| 361 | * a simple comparison works. |
| 362 | * |
| 363 | * @todo What if there is a <!-- </script> --> comment? |
| 364 | * @todo What happens when script tags are unclosed? |
| 365 | */ |
| 366 | return $opening_tags_count > $closing_tags_count; |
| 367 | } |
| 368 | |
| 369 | public static function get_slug() { |
| 370 | return 'render_blocking_js'; |
| 371 | } |
| 372 | } |