Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
93.33% |
28 / 30 |
|
80.00% |
4 / 5 |
CRAP | |
0.00% |
0 / 1 |
| Display_Critical_CSS | |
93.33% |
28 / 30 |
|
80.00% |
4 / 5 |
11.04 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| asynchronize_stylesheets | |
87.50% |
14 / 16 |
|
0.00% |
0 / 1 |
6.07 | |||
| display_critical_css | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
2 | |||
| neutralize_style_closing_tags | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| onload_flip_stylesheets | |
100.00% |
6 / 6 |
|
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 | |
| 7 | namespace Automattic\Jetpack_Boost\Lib\Critical_CSS; |
| 8 | |
| 9 | class 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 | } |