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