Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
2.53% covered (danger)
2.53%
2 / 79
16.67% covered (danger)
16.67%
2 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
Render_Blocking_JS
2.53% covered (danger)
2.53%
2 / 79
16.67% covered (danger)
16.67%
2 / 12
1375.08
0.00% covered (danger)
0.00%
0 / 1
 setup
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 is_available
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 start_output_filtering
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
462
 handle_output_stream
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
20
 get_script_tags
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 ignore_exclusion_scripts
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
2
 recalculate_buffer_split
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 append_script_tags
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 handle_exclusions
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 add_ignore_attribute
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 is_opened_script
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 get_slug
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
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
10namespace Automattic\Jetpack_Boost\Modules\Optimizations\Render_Blocking_JS;
11
12use Automattic\Jetpack_Boost\Contracts\Changes_Output_On_Activation;
13use Automattic\Jetpack_Boost\Contracts\Feature;
14use Automattic\Jetpack_Boost\Contracts\Optimization;
15use Automattic\Jetpack_Boost\Lib\Output_Filter;
16
17/**
18 * Class Render_Blocking_JS
19 */
20class 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}