Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
71.12% covered (warning)
71.12%
133 / 187
52.38% covered (warning)
52.38%
11 / 21
CRAP
0.00% covered (danger)
0.00%
0 / 1
Render_Blocking_JS
71.12% covered (warning)
71.12%
133 / 187
52.38% covered (warning)
52.38%
11 / 21
210.45
0.00% covered (danger)
0.00%
0 / 1
 setup
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 is_available
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 register_data_sync
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 get_change_output_action_names
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 start_output_filtering
65.52% covered (warning)
65.52%
19 / 29
0.00% covered (danger)
0.00%
0 / 1
41.85
 handle_output_stream
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
4.01
 get_script_tags
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 ignore_exclusion_scripts
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
2
 pin_position_dependent_scripts
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
5
 ignore_attribute_lookahead
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 recalculate_buffer_split
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 append_script_tags
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
3.02
 handle_exclusions
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 add_ignore_attribute
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 is_opened_script
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
2.00
 is_current_request_excluded
16.67% covered (danger)
16.67%
1 / 6
0.00% covered (danger)
0.00%
0 / 1
19.47
 is_url_excluded
33.33% covered (danger)
33.33%
3 / 9
0.00% covered (danger)
0.00%
0 / 1
12.41
 normalize_url_path
33.33% covered (danger)
33.33%
2 / 6
0.00% covered (danger)
0.00%
0 / 1
3.19
 strip_home_path
12.50% covered (danger)
12.50%
1 / 8
0.00% covered (danger)
0.00%
0 / 1
14.72
 get_exclusion_regex
53.12% covered (warning)
53.12%
17 / 32
0.00% covered (danger)
0.00%
0 / 1
17.34
 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\Schema\Schema;
13use Automattic\Jetpack\WP_JS_Data_Sync\Data_Sync;
14use Automattic\Jetpack_Boost\Contracts\Changes_Output_After_Activation;
15use Automattic\Jetpack_Boost\Contracts\Changes_Output_On_Activation;
16use Automattic\Jetpack_Boost\Contracts\Feature;
17use Automattic\Jetpack_Boost\Contracts\Has_Data_Sync;
18use Automattic\Jetpack_Boost\Contracts\Optimization;
19use Automattic\Jetpack_Boost\Data_Sync\Minify_Excludes_State_Entry;
20use Automattic\Jetpack_Boost\Lib\Body_Close_Locator;
21use Automattic\Jetpack_Boost\Lib\Output_Filter;
22
23/**
24 * Class Render_Blocking_JS
25 */
26class Render_Blocking_JS implements Feature, Changes_Output_On_Activation, Changes_Output_After_Activation, Optimization, Has_Data_Sync {
27    /**
28     * Substring that marks an inline script as producing position-dependent
29     * output (document.write()/document.writeln()). Such scripts must stay where
30     * they are rather than being moved to the end of the document. The fast-path
31     * guard and the per-script check must use the same needle to stay in lockstep.
32     *
33     * @var string
34     */
35    private const POSITION_DEPENDENT_OUTPUT_NEEDLE = 'document.write';
36
37    /**
38     * Holds the script tags removed from the output buffer.
39     *
40     * @var array
41     */
42    protected $buffered_script_tags = array();
43
44    /**
45     * HTML attribute name to be added to <script> tag to make it
46     * ignored by this class.
47     *
48     * @var string|null
49     */
50    private $ignore_attribute;
51
52    /**
53     * HTML attribute value to be added to <script> tag to make it
54     * ignored by this class.
55     *
56     * @var string
57     */
58    private $ignore_value = 'ignore';
59
60    /**
61     * Utility class that supports output filtering.
62     *
63     * @var Output_Filter
64     */
65    private $output_filter = null;
66
67    /**
68     * Flag indicating an opened <script> tag in output.
69     *
70     * @var string
71     */
72    private $is_opened_script = false;
73
74    public function setup() {
75        $this->output_filter = new Output_Filter();
76
77        /**
78         * Filters the ignore attribute
79         *
80         * @param $string $ignore_attribute The string used to ignore elements of the page.
81         *
82         * @since   1.0.0
83         */
84        $this->ignore_attribute = apply_filters( 'jetpack_boost_render_blocking_js_ignore_attribute', 'data-jetpack-boost' );
85
86        add_action( 'template_redirect', array( $this, 'start_output_filtering' ), -999999 );
87
88        /**
89         * Shortcodes can sometimes output script to embed widget. It's safer to ignore them.
90         */
91        add_filter( 'do_shortcode_tag', array( $this, 'add_ignore_attribute' ) );
92    }
93
94    public static function is_available() {
95        return true;
96    }
97
98    /**
99     * Register the data sync entry holding the list of URL patterns
100     * excluded from JS deferring.
101     *
102     * @param Data_Sync $instance The data sync instance.
103     */
104    public function register_data_sync( Data_Sync $instance ) {
105        $instance->register(
106            'render_blocking_js_excludes',
107            Schema::as_array( Schema::as_string() )->fallback( array() ),
108            new Minify_Excludes_State_Entry( 'render_blocking_js_excludes' )
109        );
110    }
111
112    /**
113     * Cached pages need to be invalidated when the exclusion list changes.
114     *
115     * @return string[] Action names fired when the exclusion list is updated.
116     */
117    public static function get_change_output_action_names() {
118        $option = JETPACK_BOOST_DATASYNC_NAMESPACE . '_render_blocking_js_excludes';
119
120        // `add_option_*` covers the very first save, when the option is created
121        // rather than updated, so the cache is invalidated on that write too.
122        return array(
123            'add_option_' . $option,
124            'update_option_' . $option,
125        );
126    }
127
128    /**
129     * Set up an output filtering callback.
130     *
131     * @return void
132     */
133    public function start_output_filtering() {
134        /**
135         * We're doing heavy output filtering in this module
136         * by using output buffering.
137         *
138         * Here are a few scenarios when we shouldn't do it:
139         */
140
141        /**
142         * Filter to disable defer blocking JS
143         *
144         * @param bool $defer return false to disable defer blocking
145         *
146         * @since   1.0.0
147         */
148        if ( false === apply_filters( 'jetpack_boost_should_defer_js', '__return_true' ) ) {
149            return;
150        }
151
152        // Disable in robots.txt.
153        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.
154            return;
155        }
156
157        // Disable in other possible AJAX requests setting cors related header.
158        if ( isset( $_SERVER['HTTP_SEC_FETCH_MODE'] ) && 'cors' === strtolower( $_SERVER['HTTP_SEC_FETCH_MODE'] ) ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- This is validating.
159            return;
160        }
161
162        // Disable in other possible AJAX requests setting XHR related header.
163        if ( isset( $_SERVER['HTTP_X_REQUESTED_WITH'] ) && 'xmlhttprequest' === strtolower( $_SERVER['HTTP_X_REQUESTED_WITH'] ) ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- This is validating.
164            return;
165        }
166
167        // Disable in all XLS (see the WP_Sitemaps_Renderer class which is responsible for rendering Sitemaps data to XML
168        // in accordance with sitemap protocol).
169        if ( isset( $_SERVER['REQUEST_URI'] ) &&
170            (
171                // phpcs:disable WordPress.Security.ValidatedSanitizedInput -- This is validating.
172                str_contains( $_SERVER['REQUEST_URI'], '.xsl' ) ||
173                str_contains( $_SERVER['REQUEST_URI'], 'sitemap-stylesheet=index' ) ||
174                str_contains( $_SERVER['REQUEST_URI'], 'sitemap-stylesheet=sitemap' )
175                // phpcs:enable WordPress.Security.ValidatedSanitizedInput
176            ) ) {
177            return;
178        }
179
180        // Disable in all POST Requests.
181        // phpcs:disable WordPress.Security.NonceVerification.Missing
182        if ( ! empty( $_POST ) ) {
183            return;
184        }
185
186        // Disable in customizer previews
187        if ( is_customize_preview() ) {
188            return;
189        }
190
191        // Disable in feeds, AJAX, Cron, XML.
192        if ( is_feed() || wp_doing_ajax() || wp_doing_cron() || wp_is_xml_request() ) {
193            return;
194        }
195
196        // Disable in sitemaps.
197        if ( ! empty( get_query_var( 'sitemap' ) ) ) {
198            return;
199        }
200
201        // Disable in AMP pages.
202        if ( function_exists( 'amp_is_request' ) && amp_is_request() ) {
203            return;
204        }
205
206        // Disable on URLs excluded by the user.
207        if ( $this->is_current_request_excluded() ) {
208            // Leave the page output completely untouched, as if the module was off.
209            remove_filter( 'do_shortcode_tag', array( $this, 'add_ignore_attribute' ) );
210            return;
211        }
212
213        // Print the filtered script tags to the very end of the page.
214        add_filter( 'jetpack_boost_output_filtering_last_buffer', array( $this, 'append_script_tags' ), 10, 1 );
215
216        // Handle exclusions.
217        add_filter( 'script_loader_tag', array( $this, 'handle_exclusions' ), 10, 2 );
218
219        $this->output_filter->add_callback( array( $this, 'handle_output_stream' ) );
220    }
221
222    /**
223     * Remove all inline and external <script> tags from the default output.
224     *
225     * @param string $buffer_start First part of the buffer.
226     * @param string $buffer_end   Second part of the buffer.
227     *
228     * For explanation on why there are two parts of a buffer here, see
229     * the comments and examples in the Output_Filter class.
230     *
231     * @return array Parts of the buffer.
232     */
233    public function handle_output_stream( $buffer_start, $buffer_end ) {
234        $joint_buffer = $this->ignore_exclusion_scripts( $buffer_start . $buffer_end );
235        $script_tags  = $this->get_script_tags( $joint_buffer );
236
237        if ( ! $script_tags ) {
238            if ( $this->is_opened_script ) {
239                // We have an opened script tag, move everything to the second buffer to avoid printing it to the page.
240                // We will do this until the </script> closing tag is encountered.
241                return array( '', $joint_buffer );
242            }
243
244            // No script tags detected, return both chunks unaltered.
245            return array( $buffer_start, $buffer_end );
246        }
247
248        // Makes sure all whole <script>...</script> tags are in $buffer_start.
249        list( $buffer_start, $buffer_end ) = $this->recalculate_buffer_split( $joint_buffer, $script_tags );
250
251        foreach ( $script_tags as $script_tag ) {
252            $this->buffered_script_tags[] = $script_tag[0];
253            $buffer_start                 = str_replace( $script_tag[0], '', $buffer_start );
254        }
255
256        // Detect a lingering opened script.
257        $this->is_opened_script = $this->is_opened_script( $buffer_start . $buffer_end );
258
259        return array( $buffer_start, $buffer_end );
260    }
261
262    /**
263     * Matches <script> tags with their content in a string buffer.
264     *
265     * @param string $buffer Captured piece of output buffer.
266     *
267     * @return array
268     */
269    protected function get_script_tags( $buffer ) {
270        $regex = '~<script' . $this->ignore_attribute_lookahead() . '([^>]*)>[\s\S]*?<\/script>~si';
271        preg_match_all( $regex, $buffer, $script_tags, PREG_OFFSET_CAPTURE );
272
273        // No script_tags in the joint buffer.
274        if ( empty( $script_tags[0] ) ) {
275            return array();
276        }
277
278        /**
279         * Filter to remove any scripts that should not be moved to the end of the document.
280         *
281         * @param array $script_tags array of script tags. Remove any scripts that should not be moved to the end of the documents.
282         *
283         * @since   1.0.0
284         */
285        return apply_filters( 'jetpack_boost_render_blocking_js_exclude_scripts', $script_tags[0] );
286    }
287
288    /**
289     * Adds the ignore attribute to scripts in the exclusion list.
290     *
291     * @param string $buffer Captured piece of output buffer.
292     *
293     * @return string
294     */
295    protected function ignore_exclusion_scripts( $buffer ) {
296        $exclusions = array(
297            // Scripts inside HTML comments.
298            '~<!--.*?-->~si',
299
300            // Scripts with types that do not execute complex code. Moving them down can be dangerous
301            // and does not benefit performance. Includes types: application/json, application/ld+json and importmap.
302            '~<script\s+[^\>]*type=(?<q>["\']*)(application\/(ld\+)?json|importmap)\k<q>.*?>.*?<\/script>~si',
303        );
304
305        $excluded = preg_replace_callback(
306            $exclusions,
307            function ( $script_match ) {
308                return $this->add_ignore_attribute( $script_match[0] );
309            },
310            $buffer
311        );
312        // preg_replace_callback() returns null on PCRE failure; keep the original
313        // buffer in that case rather than propagating null downstream.
314        if ( null !== $excluded ) {
315            $buffer = $excluded;
316        }
317
318        return $this->pin_position_dependent_scripts( $buffer );
319    }
320
321    /**
322     * Keep inline scripts whose output is position-dependent in their original place.
323     *
324     * Scripts using document.write()/document.writeln() insert markup at the script's
325     * location, so moving such a script to the end of the document renders its output
326     * after the footer instead of inside the content (e.g. a Custom HTML block).
327     * Marking the script with the ignore attribute keeps the rest of the pipeline
328     * from moving it. Scripts that already carry the ignore attribute are skipped so
329     * their behavior and markup are unchanged.
330     *
331     * Best-effort and deliberately conservative: it pins the common case (an inline
332     * script that calls document.write) and otherwise leaves the script to the
333     * default move behavior. It does not pin scripts that write their own
334     * '<script ...>' markup (no safe in-place edit exists), nor exotic call forms a
335     * substring check cannot see. A miss never corrupts the page â€” worst case is a
336     * script that still moves, exactly as it does without this method.
337     *
338     * @param string $buffer Captured piece of output buffer.
339     *
340     * @return string
341     */
342    private function pin_position_dependent_scripts( $buffer ) {
343        // Fast path: skip the inline-script scan entirely when the buffer cannot
344        // contain a position-dependent script.
345        if ( false === stripos( $buffer, self::POSITION_DEPENDENT_OUTPUT_NEEDLE ) ) {
346            return $buffer;
347        }
348
349        // Match inline scripts only (no src attribute) that do not already carry
350        // the ignore attribute. This runs on the buffer Output_Filter hands us
351        // (a bounded window), not a whole page; the lazy [\s\S]*? would be
352        // superlinear on a multi-megabyte single buffer, so keep it window-scoped.
353        $inline_script_regex = '~<script\b(?![^>]*\ssrc\s*=)' . $this->ignore_attribute_lookahead() . '[^>]*>[\s\S]*?</script>~i';
354
355        $result = preg_replace_callback(
356            $inline_script_regex,
357            function ( $script_match ) {
358                // Intentionally conservative: a simple case-insensitive substring check
359                // for "document.write" (which also covers "document.writeln"). It does
360                // not parse JS, so exotic call forms it cannot see â€” document['write'](),
361                // "document . write()", or an uppercase <SCRIPT> tag that
362                // add_ignore_attribute()'s lowercase replace won't touch â€” simply fall
363                // back to the default behavior (the script is moved, as it is today).
364                // That is the safe direction: a miss never corrupts the page.
365                if ( false === stripos( $script_match[0], self::POSITION_DEPENDENT_OUTPUT_NEEDLE ) ) {
366                    return $script_match[0];
367                }
368
369                // Do not touch a script that writes its own '<script ...>' markup. There
370                // is no safe in-place edit for it: add_ignore_attribute() does a global
371                // str_replace() on '<script', which rewrites the inner literal and can
372                // break the quoting of the string the script writes; tagging only the
373                // outer tag would instead let get_script_tags() match and move that inner
374                // literal. Such scripts keep the default behavior rather than risk
375                // corrupting the page.
376                if ( substr_count( strtolower( $script_match[0] ), '<script' ) > 1 ) {
377                    return $script_match[0];
378                }
379
380                return $this->add_ignore_attribute( $script_match[0] );
381            },
382            $buffer
383        );
384
385        // preg_replace_callback() returns null on PCRE failure (e.g. backtrack limit
386        // on a pathological buffer); fall back to the unmodified buffer so the page is
387        // never blanked. Mirrors the guard in is_opened_script().
388        return null === $result ? $buffer : $result;
389    }
390
391    /**
392     * Negative lookahead asserting a <script> tag does not already carry the
393     * ignore attribute. Shared by the regexes that select movable scripts so the
394     * attribute-matching rule lives in one place.
395     *
396     * @return string Regex fragment (uses named group "q"; safe to use once per pattern).
397     */
398    private function ignore_attribute_lookahead() {
399        return sprintf(
400            '(?![^>]*%s=(?<q>["\']*)%s\k<q>)',
401            preg_quote( $this->ignore_attribute, '~' ),
402            preg_quote( $this->ignore_value, '~' )
403        );
404    }
405
406    /**
407     * Splits the buffer into two parts.
408     *
409     * First part contains all whole <script> tags, the second part
410     * contains the rest of the buffer.
411     *
412     * @param string $buffer      Captured piece of output buffer.
413     * @param array  $script_tags Matched <script> tags.
414     *
415     * @return array
416     */
417    protected function recalculate_buffer_split( $buffer, $script_tags ) {
418        $last_script_tag_index        = count( $script_tags ) - 1;
419        $last_script_tag_end_position = strrpos( $buffer, $script_tags[ $last_script_tag_index ][0] ) + strlen( $script_tags[ $last_script_tag_index ][0] );
420
421        // Bundle all script tags into the first buffer.
422        $buffer_start = substr( $buffer, 0, $last_script_tag_end_position );
423
424        // Leave the rest of the data in the second buffer.
425        $buffer_end = substr( $buffer, $last_script_tag_end_position );
426
427        return array( $buffer_start, $buffer_end );
428    }
429
430    /**
431     * Insert the buffered script tags just before the document's closing body
432     * tag if the last buffer holds one, otherwise append them at the end.
433     *
434     * The closing tag is located with an HTML tokenizer rather than string
435     * search: a literal '</body>' inside a script's source (document.write),
436     * a textarea, a comment or an attribute value is content, not markup, and
437     * inserting there corrupts the page (BOOST-585).
438     *
439     * @param string $buffer String buffer.
440     *
441     * @return string
442     */
443    public function append_script_tags( $buffer ) {
444        $script_tags = implode( '', $this->buffered_script_tags );
445        // Reset tags in case there's another buffer after this one.
446        $this->buffered_script_tags = array();
447
448        // Nothing to insert: both branches below are identity operations, so
449        // skip the buffer scan entirely. Any other feature registering an
450        // Output_Filter on the same global hook â€” Lcp does â€” calls this a
451        // second time per request.
452        if ( '' === $script_tags ) {
453            return $buffer;
454        }
455
456        $position = Body_Close_Locator::find( $buffer );
457        if ( null === $position ) {
458            return $buffer . $script_tags;
459        }
460
461        return substr_replace( $buffer, $script_tags, $position, 0 );
462    }
463
464    /**
465     * Exclude certain scripts from being processed by this class.
466     *
467     * @param string $tag    <script> opening tag.
468     * @param string $handle Script handle from register_ or enqueue_ methods.
469     *
470     * @return string
471     */
472    public function handle_exclusions( $tag, $handle ) {
473        /**
474         * Filter to provide an array of registered script handles that should not be moved to the end of the document.
475         *
476         * @param array $script_handles array of script handles. Remove any scripts that should not be moved to the end of the documents.
477         *
478         * @since   1.0.0
479         */
480        $exclude_handles = apply_filters( 'jetpack_boost_render_blocking_js_exclude_handles', array() );
481
482        if ( ! in_array( $handle, $exclude_handles, true ) ) {
483            return $tag;
484        }
485
486        return $this->add_ignore_attribute( $tag );
487    }
488
489    /**
490     * Add the ignore attribute to the script tags.
491     *
492     * Case-insensitive so uppercase/mixed-case tags (`<SCRIPT>`, valid HTML and
493     * common in hand-written Custom HTML / legacy embeds) are handled too; a
494     * case-sensitive match would silently no-op on them and leave them movable.
495     *
496     * @param string $html HTML code possibly containing a <script> opening tag.
497     *
498     * @return string
499     */
500    public function add_ignore_attribute( $html ) {
501        return str_ireplace( '<script', sprintf( '<script %s="%s"', esc_html( $this->ignore_attribute ), esc_attr( $this->ignore_value ) ), $html );
502    }
503
504    /**
505     * Detects an unclosed script tag in a buffer.
506     *
507     * @param string $buffer Joint buffer.
508     *
509     * @return bool
510     */
511    public function is_opened_script( $buffer ) {
512        // Strip fully-paired ignored <script>...</script> blocks so the counts below are symmetric.
513        $ignored_pair_regex = sprintf(
514            '~<script[^>]*%s=(?<q>["\']*)%s\k<q>[^>]*>[\s\S]*?</script>~si',
515            preg_quote( $this->ignore_attribute, '~' ),
516            preg_quote( $this->ignore_value, '~' )
517        );
518        $stripped           = preg_replace( $ignored_pair_regex, '', $buffer );
519        if ( null === $stripped ) {
520            $stripped = $buffer;
521        }
522
523        // Strip HTML comments so a commented-out </script> doesn't skew the count.
524        $stripped = preg_replace( '~<!--[\s\S]*?-->~', '', $stripped ) ?? $stripped;
525
526        $opening_tags_count = preg_match_all( '~<\s*script(\s[^>]*)?>~i', $stripped );
527        $closing_tags_count = preg_match_all( '~<\s*/\s*script\s*>~i', $stripped );
528
529        return $opening_tags_count > $closing_tags_count;
530    }
531
532    /**
533     * Checks if the current request URL matches one of the exclusion patterns
534     * configured by the user.
535     *
536     * Runs at template_redirect time, when REQUEST_URI is available.
537     *
538     * @return bool
539     */
540    private function is_current_request_excluded() {
541        if ( ! isset( $_SERVER['REQUEST_URI'] ) ) {
542            return false;
543        }
544
545        $patterns = function_exists( 'jetpack_boost_ds_get' ) ? jetpack_boost_ds_get( 'render_blocking_js_excludes' ) : array();
546        if ( empty( $patterns ) || ! is_array( $patterns ) ) {
547            return false;
548        }
549
550        // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Only used for comparison.
551        return self::is_url_excluded( wp_unslash( $_SERVER['REQUEST_URI'] ), $patterns );
552    }
553
554    /**
555     * Checks whether a request URI matches any of the given exclusion patterns.
556     *
557     * Patterns follow the semantics documented for Page Cache bypass patterns:
558     * they are compared against the URL path (query strings are ignored),
559     * a `(.*)` or `*` wildcard matches any part of the path, trailing slashes
560     * are optional and the comparison is case-insensitive.
561     *
562     * Two things differ from Page Cache, so keep them in mind before unifying the
563     * two implementations: every character outside the wildcard tokens is escaped
564     * via preg_quote() and matched literally (a pattern like `page.html` never
565     * acts as a regular expression), and the path is percent-decoded so a pattern
566     * typed as it appears in the address bar matches an encoded request path.
567     *
568     * @param string $request_uri The request URI to check.
569     * @param array  $patterns    List of URL patterns.
570     *
571     * @return bool
572     */
573    public static function is_url_excluded( $request_uri, $patterns ) {
574        $path = self::normalize_url_path( $request_uri );
575
576        foreach ( $patterns as $pattern ) {
577            $regex = self::get_exclusion_regex( $pattern );
578            if ( null === $regex ) {
579                continue;
580            }
581
582            $matched = preg_match( $regex, $path );
583
584            /*
585             * preg_match() returns false when PCRE cannot evaluate the pattern â€”
586             * e.g. a pathological pattern with several literal-separated wildcards
587             * hits the backtrack limit on a long URL. Treat that as a match so a
588             * deliberate exclusion is honoured (defer stays off on the page)
589             * rather than silently ignored.
590             */
591            if ( 1 === $matched || false === $matched ) {
592                return true;
593            }
594        }
595
596        return false;
597    }
598
599    /**
600     * Extracts a normalized path from a URL or request URI.
601     *
602     * Drops the query string, ensures a leading slash and removes trailing
603     * slashes (except for the root path).
604     *
605     * @param string $url URL or request URI.
606     *
607     * @return string
608     */
609    private static function normalize_url_path( $url ) {
610        $path = (string) wp_parse_url( $url, PHP_URL_PATH );
611
612        // Decode percent-encoding so a pattern typed as it appears in the address
613        // bar (e.g. `foo bar`, or a non-ASCII slug) matches the encoded request
614        // path (`/foo%20bar`). Both the pattern and the request pass through here,
615        // so the two sides stay symmetric.
616        $path = rawurldecode( $path );
617
618        $path = '/' . ltrim( $path, '/' );
619
620        if ( '/' !== $path ) {
621            $path = rtrim( $path, '/' );
622        }
623
624        return self::strip_home_path( $path );
625    }
626
627    /**
628     * Removes the site's home directory prefix from a path.
629     *
630     * On a subdirectory install (e.g. a site at `/blog/`) the request URI
631     * includes the subdirectory but user-entered patterns generally do not.
632     * Stripping the home directory from both sides makes the comparison relative
633     * to the home root, so a `checkout` pattern matches `/blog/checkout`.
634     *
635     * @param string $path A normalized URL path (leading slash, no query/trailing slash).
636     *
637     * @return string
638     */
639    private static function strip_home_path( $path ) {
640        $home_path = rtrim( (string) wp_parse_url( home_url( '/' ), PHP_URL_PATH ), '/' );
641
642        if ( '' === $home_path ) {
643            return $path;
644        }
645
646        if ( 0 === strcasecmp( $path, $home_path ) ) {
647            return '/';
648        }
649
650        if ( 0 === strncasecmp( $path, $home_path . '/', strlen( $home_path ) + 1 ) ) {
651            return substr( $path, strlen( $home_path ) );
652        }
653
654        return $path;
655    }
656
657    /**
658     * Turns a single exclusion pattern into an anchored regular expression.
659     *
660     * @param mixed $pattern A user-provided URL pattern.
661     *
662     * @return string|null The regular expression, or null if the pattern is empty.
663     */
664    private static function get_exclusion_regex( $pattern ) {
665        if ( ! is_string( $pattern ) ) {
666            return null;
667        }
668
669        $pattern = trim( $pattern );
670        if ( '' === $pattern ) {
671            return null;
672        }
673
674        /*
675         * Reject malformed URL patterns. A full URL with a scheme but no path
676         * (e.g. a typo'd `http://[::1`) would otherwise collapse to `/` and
677         * silently exclude only the homepage. A pathless URL that points at this
678         * site (e.g. the home URL pasted as `https://example.com`) is allowed
679         * through, since it legitimately means the homepage.
680         */
681        $parsed = wp_parse_url( $pattern );
682        if ( false === $parsed ) {
683            return null;
684        }
685        if ( isset( $parsed['scheme'] ) && empty( $parsed['path'] ) ) {
686            $home_host = wp_parse_url( home_url( '/' ), PHP_URL_HOST );
687            if ( empty( $parsed['host'] ) || 0 !== strcasecmp( $parsed['host'], (string) $home_host ) ) {
688                return null;
689            }
690        }
691
692        // Allow full URLs by stripping the home URL prefix (both secure and non-secure).
693        $home_url = home_url( '/' );
694        $pattern  = str_ireplace(
695            array(
696                $home_url,
697                str_replace( 'http:', 'https:', $home_url ),
698            ),
699            '/',
700            $pattern
701        );
702
703        $pattern = self::normalize_url_path( $pattern );
704
705        /*
706         * Split on wildcard tokens, treating any run of adjacent wildcards as a
707         * single split point. The possessive `++` is important: without coalescing,
708         * a pattern such as `****` would expand to one `.*` group per character, and
709         * thousands of wildcards would compile to thousands of groups and exhaust
710         * memory when matched against every front-end request. Possessive (rather
711         * than greedy `+`) keeps the split itself linear, so a pathological run of
712         * thousands of adjacent wildcards cannot exhaust the PCRE backtrack/JIT
713         * stack and make preg_split() return false.
714         */
715        $tokens = preg_split( '/(?:\(\.\*\)|\(\*\)|\.\*|\*)++/', $pattern );
716        if ( false === $tokens ) {
717            return null;
718        }
719
720        // Everything between wildcards is matched literally; only the wildcards
721        // become a (non-capturing) `.*` group.
722        $quoted = array_map(
723            function ( $token ) {
724                return preg_quote( $token, '~' );
725            },
726            $tokens
727        );
728
729        return '~^' . implode( '(?:.*)', $quoted ) . '/?$~i';
730    }
731
732    public static function get_slug() {
733        return 'render_blocking_js';
734    }
735}