Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
63.69% covered (warning)
63.69%
107 / 168
37.50% covered (danger)
37.50%
3 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
Concatenate_JS
63.69% covered (warning)
63.69%
107 / 168
37.50% covered (danger)
37.50%
3 / 8
507.03
0.00% covered (danger)
0.00%
0 / 1
 __construct
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
5.03
 has_inline_content
70.00% covered (warning)
70.00%
7 / 10
0.00% covered (danger)
0.00%
0 / 1
4.43
 do_items
58.96% covered (warning)
58.96%
79 / 134
0.00% covered (danger)
0.00%
0 / 1
498.69
 get_script_type
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
2.00
 __isset
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 __unset
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 __get
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 __set
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3namespace Automattic\Jetpack_Boost\Lib\Minify;
4
5use WP_Scripts;
6
7// Disable complaints about enqueuing scripts, as this class alters the way enqueuing them works.
8// phpcs:disable WordPress.WP.EnqueuedResources.NonEnqueuedScript
9
10/**
11 * Replacement for, and subclass of WP_Scripts - used to control the way that scripts are enqueued and output.
12 */
13class Concatenate_JS extends WP_Scripts {
14    private $dependency_path_mapping;
15    private $old_scripts;
16
17    public $allow_gzip_compression;
18
19    public function __construct( $scripts ) {
20        if ( empty( $scripts ) || ! ( $scripts instanceof WP_Scripts ) ) {
21            $this->old_scripts = new WP_Scripts();
22        } else {
23            $this->old_scripts = $scripts;
24        }
25
26        // Unset all the object properties except our private copy of the scripts object.
27        // We have to unset everything so that the overload methods talk to $this->old_scripts->whatever
28        // instead of $this->whatever.
29        foreach ( array_keys( get_object_vars( $this ) ) as $key ) {
30            if ( 'old_scripts' === $key ) {
31                continue;
32            }
33            unset( $this->$key );
34        }
35
36        $this->dependency_path_mapping = new Dependency_Path_Mapping(
37            /**
38             * Filter the URL of the site the plugin will be concatenating CSS or JS on
39             *
40             * @param bool $url URL of the page with CSS or JS to concatonate.
41             *
42             * @since   1.0.0
43             */
44            apply_filters( 'page_optimize_site_url', $this->base_url )
45        );
46    }
47
48    protected function has_inline_content( $handle ) {
49        $before_output = $this->get_data( $handle, 'before' );
50        if ( ! empty( $before_output ) ) {
51            return true;
52        }
53
54        $after_output = $this->get_data( $handle, 'after' );
55        if ( ! empty( $after_output ) ) {
56            return true;
57        }
58
59        // JavaScript translations
60        $has_translations = ! empty( $this->registered[ $handle ]->textdomain );
61        if ( $has_translations ) {
62            return true;
63        }
64
65        return false;
66    }
67
68    /**
69     * Override for WP_Scripts::do_item() - this is the method that actually outputs the scripts.
70     */
71    public function do_items( $handles = false, $group = false ) {
72        $handles     = false === $handles ? $this->queue : (array) $handles;
73        $javascripts = array();
74        /**
75         * Filter the URL of the site the plugin will be concatenating CSS or JS on
76         *
77         * @param bool $url URL of the page with CSS or JS to concatonate.
78         *
79         * @since   1.0.0
80         */
81        $siteurl = apply_filters( 'page_optimize_site_url', $this->base_url );
82        $this->all_deps( $handles );
83        $level = 0;
84
85        $using_strict = false;
86        $strict_count = 0;
87        foreach ( $this->to_do as $key => $handle ) {
88            $script_is_strict = false;
89            if ( in_array( $handle, $this->done, true ) || ! isset( $this->registered[ $handle ] ) ) {
90                continue;
91            }
92
93            if ( 0 === $group && $this->groups[ $handle ] > 0 ) {
94                $this->in_footer[] = $handle;
95                unset( $this->to_do[ $key ] );
96                continue;
97            }
98
99            if ( ! $this->registered[ $handle ]->src ) { // Defines a group.
100                if ( $this->has_inline_content( $handle ) ) {
101                    ++$level;
102                    $javascripts[ $level ]['type']   = 'do_item';
103                    $javascripts[ $level ]['handle'] = $handle;
104                    ++$level;
105                    unset( $this->to_do[ $key ] );
106                } else {
107                    // if there are localized items, echo them
108                    $this->print_extra_script( $handle );
109                    $this->done[] = $handle;
110                }
111                continue;
112            }
113
114            if ( false === $group && in_array( $handle, $this->in_footer, true ) ) {
115                $this->in_footer = array_diff( $this->in_footer, (array) $handle );
116            }
117
118            $obj           = $this->registered[ $handle ];
119            $js_url        = jetpack_boost_enqueued_to_absolute_url( $obj->src );
120            $js_url_parsed = wp_parse_url( $js_url );
121
122            // Don't concat by default
123            $do_concat = false;
124
125            // Only try to concat static js files
126            if ( str_contains( $js_url_parsed['path'], '.js' ) ) {
127                // Previously, the value of this variable was determined by a function.
128                // Now, since concatenation is always enabled when the module is active,
129                // the value will always be true for static files.
130                $do_concat = true;
131            } elseif ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
132                    printf( "\n<!-- No Concat JS %s => Maybe Not Static File %s -->\n", esc_html( $handle ), esc_html( $obj->src ) );
133            }
134
135            // Don't try to concat externally hosted scripts
136            $is_internal_uri = $this->dependency_path_mapping->is_internal_uri( $js_url );
137            if ( $do_concat && ! $is_internal_uri ) {
138                if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
139                    printf( "\n<!-- No Concat JS %s => External URL: %s -->\n", esc_html( $handle ), esc_url( $js_url ) );
140                }
141                $do_concat = false;
142            }
143
144            $js_realpath = false;
145            if ( $do_concat ) {
146                // Resolve paths and concat scripts that exist in the filesystem
147                $js_realpath = $this->dependency_path_mapping->dependency_src_to_fs_path( $js_url );
148                if ( false === $js_realpath ) {
149                    if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
150                        printf( "\n<!-- No Concat JS %s => Invalid Path %s -->\n", esc_html( $handle ), esc_html( $js_realpath ) );
151                    }
152                    $do_concat = false;
153                }
154            }
155
156            if ( $do_concat && in_array( $this->get_data( $handle, 'strategy' ), array( 'defer', 'async' ), true ) ) {
157                $do_concat = false;
158            }
159
160            if ( $do_concat && $this->has_inline_content( $handle ) ) {
161                if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
162                    printf( "\n<!-- No Concat JS %s => Has Inline Content -->\n", esc_html( $handle ) );
163                }
164                $do_concat = false;
165            }
166
167            // Skip core scripts that use Strict Mode
168            if ( $do_concat && ( 'react' === $handle || 'react-dom' === $handle ) ) {
169                if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
170                    printf( "\n<!-- No Concat JS %s => Has Strict Mode (Core) -->\n", esc_html( $handle ) );
171                }
172                $do_concat        = false;
173                $script_is_strict = true;
174            // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
175            } elseif ( $do_concat && preg_match_all( '/^[\',"]use strict[\',"];/Uims', file_get_contents( $js_realpath ), $matches ) ) {
176                // Skip third-party scripts that use Strict Mode
177                if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
178                    printf( "\n<!-- No Concat JS %s => Has Strict Mode (Third-Party) -->\n", esc_html( $handle ) );
179                }
180                $do_concat        = false;
181                $script_is_strict = true;
182            } else {
183                $script_is_strict = false;
184            }
185
186            // Skip concating scripts from exclusion list
187            $exclude_list = jetpack_boost_page_optimize_js_exclude_list();
188            foreach ( $exclude_list as $exclude ) {
189                if ( $do_concat && $handle === $exclude ) {
190                    $do_concat = false;
191                    if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
192                        printf( "\n<!-- No Concat JS %s => Excluded option -->\n", esc_html( $handle ) );
193                    }
194                }
195            }
196
197            /** This filter is documented in wp-includes/class-wp-scripts.php */
198            $js_url = esc_url_raw( apply_filters( 'script_loader_src', $js_url, $handle ) );
199            if ( ! $js_url ) {
200                $do_concat = false;
201                if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
202                    printf( "\n<!-- No Concat JS %s => No URL -->\n", esc_html( $handle ) );
203                }
204            } elseif ( 'module' === $this->get_script_type( $handle, $js_url ) ) {
205                $do_concat = false;
206                if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
207                    printf( "\n<!-- No Concat JS %s => Module Script -->\n", esc_html( $handle ) );
208                }
209            }
210
211            /**
212             * Filter that allows plugins to disable concatenation of certain scripts.
213             *
214             * @param bool $do_concat if true, then perform concatenation
215             * @param string $handle handle to JS file
216             *
217             * @since   1.0.0
218             */
219            if ( $do_concat && ! apply_filters( 'js_do_concat', $do_concat, $handle ) ) {
220                if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
221                    printf( "\n<!-- No Concat JS %s => Filtered `false` -->\n", esc_html( $handle ) );
222                }
223            }
224            /**
225             * Filter that allows plugins to disable concatenation of certain scripts.
226             *
227             * @param bool $do_concat if true, then perform concatenation
228             * @param string $handle handle to JS file
229             *
230             * @since   1.0.0
231             */
232            $do_concat = apply_filters( 'js_do_concat', $do_concat, $handle );
233
234            if ( true === $do_concat ) {
235
236                // If the number of files in the group is greater than the maximum, start a new group.
237                if ( isset( $javascripts[ $level ] ) && count( $javascripts[ $level ]['handles'] ) >= jetpack_boost_minify_concat_max_files() ) {
238                    ++$level;
239                }
240
241                if ( ! isset( $javascripts[ $level ] ) ) {
242                    $javascripts[ $level ]['type'] = 'concat';
243                }
244
245                $javascripts[ $level ]['paths'][]   = $js_url_parsed['path'];
246                $javascripts[ $level ]['handles'][] = $handle;
247
248            } else {
249                ++$level;
250                $javascripts[ $level ]['type']   = 'do_item';
251                $javascripts[ $level ]['handle'] = $handle;
252                ++$level;
253            }
254            unset( $this->to_do[ $key ] );
255
256            if ( $using_strict !== $script_is_strict ) {
257                if ( $script_is_strict ) {
258                    $using_strict = true;
259                    $strict_count = 0;
260                } else {
261                    $using_strict = false;
262                }
263            }
264
265            if ( $script_is_strict ) {
266                ++$strict_count;
267            }
268        }
269
270        if ( empty( $javascripts ) ) {
271            return $this->done;
272        }
273
274        foreach ( $javascripts as $js_array ) {
275            if ( 'do_item' === $js_array['type'] ) {
276                if ( $this->do_item( $js_array['handle'], $group ) ) {
277                    $this->done[] = $js_array['handle'];
278                }
279            } elseif ( 'concat' === $js_array['type'] ) {
280                array_map( array( $this, 'print_extra_script' ), $js_array['handles'] );
281
282                if ( isset( $js_array['paths'] ) && count( $js_array['paths'] ) > 1 ) {
283                    $file_name = jetpack_boost_page_optimize_generate_concat_path( $js_array['paths'], $this->dependency_path_mapping );
284
285                    if ( jetpack_boost_minify_use_static_cache_urls() ) {
286                        $href = jetpack_boost_get_minify_url( $file_name . '.min.js' );
287                    } else {
288                        $href = $siteurl . jetpack_boost_get_static_prefix() . '??' . $file_name;
289                    }
290                } elseif ( isset( $js_array['paths'] ) && is_array( $js_array['paths'] ) ) {
291                    $href = jetpack_boost_page_optimize_cache_bust_mtime( $js_array['paths'][0], $siteurl );
292                }
293
294                $this->done = array_merge( $this->done, $js_array['handles'] );
295
296                // Print before/after scripts from wp_inline_scripts() and concatenated script tag
297                if ( isset( $js_array['extras']['before'] ) ) {
298                    foreach ( $js_array['extras']['before'] as $inline_before ) {
299                        // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
300                        echo $inline_before;
301                    }
302                }
303
304                if ( isset( $href ) ) {
305                    $handles = implode( ',', $js_array['handles'] );
306
307                    if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
308                        $tag = "<script data-handles='" . esc_attr( $handles ) . "' type='text/javascript' src='" . esc_url( $href ) . "'></script>\n";
309                    } else {
310                        $tag = "<script type='text/javascript' src='" . esc_url( $href ) . "'></script>\n";
311                    }
312
313                    if ( is_array( $js_array['handles'] ) && count( $js_array['handles'] ) === 1 ) {
314                        /**
315                         * Filters the HTML script tag of an enqueued script
316                         * A copy of the core filter of the same name. https://developer.wordpress.org/reference/hooks/script_loader_tag/
317                         * Because we have a single script, let's apply the `script_loader_tag` filter as core does in `do_item()`.
318                         * That way, we interfere less with plugin and theme script filtering. For example, without this filter,
319                         * there is a case where we block the TwentyTwenty theme from adding async/defer attributes.
320                         * https://github.com/Automattic/page-optimize/pull/44
321                         *
322                         * @param string $tag Script tag for the enqueued script.
323                         * @param string $handle The script's registered handle.
324                         * @param string $href URL of the script.
325                         *
326                         * @since   1.0.0
327                         */
328                        $tag = apply_filters( 'script_loader_tag', $tag, $js_array['handles'][0], $href );
329                    }
330
331                    // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
332                    echo $tag;
333                }
334
335                if ( isset( $js_array['extras']['after'] ) ) {
336                    foreach ( $js_array['extras']['after'] as $inline_after ) {
337                        // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
338                        echo $inline_after;
339                    }
340                }
341            }
342        }
343
344        do_action( 'js_concat_did_items', $javascripts );
345
346        return $this->done;
347    }
348
349    /**
350     * Returns the type of the script.
351     * module, text/javascript, etc. False if the script tag is invalid,
352     * or the type is not set.
353     *
354     * @since 4.1.2
355     *
356     * @param string $handle The script's registered handle.
357     * @param string $src The script's source URL.
358     *
359     * @return string|false The type of the script. False if the script tag is invalid,
360     * or the type is not set.
361     */
362    private function get_script_type( $handle, $src ) {
363        $script_tag_attr = array(
364            'src' => $src,
365            'id'  => "$handle-js",
366        );
367
368        // Get the script tag and allow plugins to filter it.
369        $script_tag = wp_get_script_tag( $script_tag_attr );
370
371        // This is a workaround to get the type of the script without outputting it.
372        $script_tag = apply_filters( 'script_loader_tag', $script_tag, $handle, $src );
373        $processor  = new \WP_HTML_Tag_Processor( $script_tag );
374
375        // If for some reason the script tag isn't valid, bail.
376        if ( ! $processor->next_tag() ) {
377            return false;
378        }
379
380        return $processor->get_attribute( 'type' );
381    }
382
383    public function __isset( $key ) {
384        return isset( $this->old_scripts->$key );
385    }
386
387    public function __unset( $key ) {
388        unset( $this->old_scripts->$key );
389    }
390
391    public function &__get( $key ) {
392        return $this->old_scripts->$key;
393    }
394
395    public function __set( $key, $value ) {
396        $this->old_scripts->$key = $value;
397    }
398}