Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
93.33% covered (success)
93.33%
28 / 30
80.00% covered (warning)
80.00%
4 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
Display_Critical_CSS
93.33% covered (success)
93.33%
28 / 30
80.00% covered (warning)
80.00%
4 / 5
11.04
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 asynchronize_stylesheets
87.50% covered (warning)
87.50%
14 / 16
0.00% covered (danger)
0.00%
0 / 1
6.07
 display_critical_css
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 neutralize_style_closing_tags
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 onload_flip_stylesheets
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * Class that's responsible for rendering
4 * Critical CSS on the site front-end.
5 */
6
7namespace Automattic\Jetpack_Boost\Lib\Critical_CSS;
8
9class Display_Critical_CSS {
10
11    /**
12     * @var string The Critical CSS to display.
13     */
14    protected $css;
15
16    /**
17     * @param string $css
18     */
19    public function __construct( $css ) {
20        $this->css = $css;
21    }
22
23    /**
24     * Converts existing screen CSS to be asynchronously loaded.
25     *
26     * @param string $html   The link tag for the enqueued style.
27     * @param string $handle The style's registered handle.
28     * @param string $href   The stylesheet's source URL.
29     * @param string $media  The stylesheet's media attribute.
30     *
31     * @return string
32     * @see style_loader_tag
33     */
34    public function asynchronize_stylesheets(
35        $html,
36        $handle,
37        $href,
38        $media
39    ) {
40        // If there is no critical CSS, do not alter the stylesheet loading.
41        if ( ! $this->css ) {
42            return $html;
43        }
44
45        $supported_loading_methods = array( 'async', 'deferred' );
46
47        /**
48         * Loading method for stylesheets.
49         *
50         * Filter the loading method for each stylesheet for the screen with following values:
51         *     async    - Stylesheets are loaded asynchronously.
52         *                Styles are applied once the stylesheet is loaded completely without render blocking.
53         *     deferred - Loading of stylesheets are deferred until the window load event.
54         *                Styles from all the stylesheets are applied at once after the page load.
55         *
56         * Stylesheet loading behaviour is not altered for any other value such as false or 'default'.
57         * Stylesheet loading is instant and the process blocks the page rendering.
58         *     Eg: add_filter( 'jetpack_boost_async_style', '__return_false' );
59         *
60         * @param string $handle The style's registered handle.
61         * @param string $media  The stylesheet's media attribute.
62         *
63         * @see   onload_flip_stylesheets for how stylesheets loading is deferred.
64         *
65         * @todo  Retrieve settings from database, either via auto-configuration or UI option.
66         */
67        $method = apply_filters( 'jetpack_boost_async_style', 'async', $handle, $media );
68
69        // If the loading method is not supported, do not alter the stylesheet loading.
70        if ( ! in_array( $method, $supported_loading_methods, true ) ) {
71            return $html;
72        }
73
74        // Update the stylesheet markup for supported loading methods using WordPress HTML API.
75        $processor = new \WP_HTML_Tag_Processor( $html );
76        if ( ! $processor->next_tag( 'link' ) ) {
77            return $html;
78        }
79
80        // Only process if this is a stylesheet link tag.
81        if ( 'stylesheet' !== $processor->get_attribute( 'rel' ) ) {
82            return $html;
83        }
84
85        // Set the new attributes based on the selected method.
86        $processor->set_attribute( 'media', 'not all' );
87        $processor->set_attribute( 'data-media', $media );
88        if ( 'async' === $method ) {
89            $processor->set_attribute( 'onload', "this.media=this.dataset.media; delete this.dataset.media; this.removeAttribute( 'onload' );" );
90        }
91
92        // Prepend the original HTML stylesheet tag within the noscript tag
93        // to support the rendering of the stylesheet when JavaScript is disabled.
94        return '<noscript>' . $html . '</noscript>' . $processor->get_updated_html();
95    }
96
97    /**
98     * Prints the critical CSS to the page.
99     */
100    public function display_critical_css() {
101        $critical_css = $this->css;
102
103        if ( ! $critical_css ) {
104            return false;
105        }
106
107        echo '<style id="jetpack-boost-critical-css">';
108
109        // Ensure the CSS cannot terminate the style element early.
110        // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
111        echo self::neutralize_style_closing_tags( $critical_css );
112
113        echo '</style>';
114    }
115
116    /**
117     * Neutralize any closing </style tag so CSS can be printed inside a <style>
118     * element without breaking out of it.
119     *
120     * This is NOT a general-purpose CSS sanitizer: it does exactly one thing,
121     * which is to stop the CSS from terminating the surrounding <style> element.
122     * It deliberately avoids wp_strip_all_tags(), which corrupts valid CSS values
123     * that contain markup - e.g. `background-image: url("data:image/svg+xml,<svg ...></svg>")`.
124     *
125     * Per the HTML rawtext tokenizer, a <style> element can only be terminated by
126     * the literal sequence `</style` (case-insensitive). Escaping its forward slash
127     * to `<\/style` defeats the tokenizer - `</` must be immediately followed by the
128     * tag name, and `<\` is treated as literal text - so the markup stays inert. The
129     * replacement string contains no `</style` substring, so a single left-to-right
130     * pass cannot reconstruct the sequence (including from nested input like
131     * `<</style/style`), and the transform is idempotent.
132     *
133     * Inside CSS strings and url() tokens `\/` is a valid escape for `/`, so
134     * legitimate quoted values keep their meaning. (A literal `</style` outside a
135     * quoted string does not occur in well-formed CSS; the security guarantee takes
136     * priority there regardless.)
137     *
138     * @param string $css CSS to neutralize.
139     * @return string CSS that cannot terminate the surrounding <style> element.
140     */
141    public static function neutralize_style_closing_tags( $css ) {
142        return str_ireplace( '</style', '<\/style', $css );
143    }
144
145    /**
146     * Add a small piece of JavaScript to the footer, which on load flips all
147     * linked stylesheets from media="not all" to "all", and switches the
148     * Critical CSS <style> block to media="not all" to deactivate it.
149     */
150    public function onload_flip_stylesheets() {
151        /*
152            Unminified version of footer script.
153
154        ?>
155            <script>
156                window.addEventListener( 'load', function() {
157
158                    // Flip all media="not all" links to media="all".
159                    document.querySelectorAll( 'link' ).forEach(
160                        function( link ) {
161                            if ( link.media === 'not all' && link.dataset.media ) {
162                                link.media = link.dataset.media;
163                                delete link.dataset.media;
164                            }
165                        }
166                    );
167
168                    // Turn off Critical CSS style block with media="not all".
169                    var element = document.getElementById( 'jetpack-boost-critical-css' );
170                    if ( element ) {
171                        element.media = 'not all';
172                    }
173
174                } );
175            </script>
176        <?php
177        */
178
179        // Minified version of footer script. See above comment for unminified version.
180        ?>
181        <script>window.addEventListener( 'load', function() {
182                document.querySelectorAll( 'link' ).forEach( function( e ) {'not all' === e.media && e.dataset.media && ( e.media = e.dataset.media, delete e.dataset.media );} );
183                var e = document.getElementById( 'jetpack-boost-critical-css' );
184                e && ( e.media = 'not all' );
185            } );</script>
186        <?php
187    }
188}