| @@ -10,9 +10,12 @@ | ||
| 10 | 10 | /** |
| 11 | 11 | * Generates and enqueues dynamic CSS for rendered slider blocks. |
| 12 | 12 | * |
| 13 | 13 | * Collects block-level styles during rendering, combines and minifies |
| 14 | - * them into a single CSS file, and enqueues it for the current page. | |
| 14 | + * them, and then either writes them to a cached file in the uploads | |
| 15 | + * directory or prints them inline, depending on the configured delivery | |
| 16 | + * mode. Inline output is also used as a fallback whenever the file | |
| 17 | + * cannot be written, so a page is never served without its styles. | |
| 15 | 18 | * |
| 16 | 19 | * @package GutSlider\Style |
| 17 | 20 | * @since 3.0.0 |
| 18 | 21 | */ |
| @@ -20,37 +23,57 @@ | ||
| 20 | 23 | |
| 21 | 24 | /** |
| 22 | 25 | * Collected CSS styles from rendered blocks. |
| 23 | 26 | * |
| 27 | + * Keyed by a hash of the style so that repeated blocks -- a synced | |
| 28 | + * pattern used twice, for instance -- contribute their CSS only once. | |
| 29 | + * | |
| 24 | 30 | * @since 3.0.0 |
| 25 | - * @var array<int, string> | |
| 31 | + * @var array<string, string> | |
| 26 | 32 | */ |
| 27 | 33 | private array $styles = array(); |
| 28 | 34 | |
| 29 | 35 | /** |
| 30 | - * Absolute path to the CSS upload directory. | |
| 36 | + * Absolute path to the CSS upload directory, or null until resolved. | |
| 31 | 37 | * |
| 32 | 38 | * @since 3.0.0 |
| 33 | - * @var string | |
| 39 | + * @var string|null | |
| 34 | 40 | */ |
| 35 | - private string $upload_dir; | |
| 41 | + private ?string $upload_dir = null; | |
| 36 | 42 | |
| 37 | 43 | /** |
| 38 | - * Public URL to the CSS upload directory. | |
| 44 | + * Public URL to the CSS upload directory, or null until resolved. | |
| 39 | 45 | * |
| 40 | 46 | * @since 3.0.0 |
| 41 | - * @var string | |
| 47 | + * @var string|null | |
| 42 | 48 | */ |
| 43 | - private string $upload_url; | |
| 49 | + private ?string $upload_url = null; | |
| 44 | 50 | |
| 45 | 51 | /** |
| 46 | 52 | * Constructor. |
| 47 | 53 | * |
| 48 | - * Sets up the upload directory and registers rendering hooks. | |
| 54 | + * @since 3.0.0 | |
| 55 | + */ | |
| 56 | + public function __construct() { | |
| 57 | + add_action( 'wp', array( $this, 'register_render_hooks' ) ); | |
| 58 | + } | |
| 59 | + | |
| 60 | + /** | |
| 61 | + * Register the collection and output hooks for a front end request. | |
| 49 | 62 | * |
| 63 | + * Deferred to `wp` so the active theme is fully resolved before | |
| 64 | + * `wp_is_block_theme()` decides where the stylesheet is printed, and so | |
| 65 | + * admin, REST and cron requests never collect styles they cannot use. | |
| 66 | + * | |
| 50 | 67 | * @since 3.0.0 |
| 68 | + * | |
| 69 | + * @return void | |
| 51 | 70 | */ |
| 52 | - public function __construct() { | |
| 71 | + public function register_render_hooks(): void { | |
| 72 | + if ( is_admin() || is_feed() ) { | |
| 73 | + return; | |
| 74 | + } | |
| 75 | + | |
| 53 | 76 | add_filter( 'render_block', array( $this, 'collect_block_styles' ), 10, 2 ); |
| 54 | 77 | |
| 55 | 78 | if ( wp_is_block_theme() ) { |
| 56 | 79 | add_action( 'wp_enqueue_scripts', array( $this, 'generate_and_enqueue_combined_css' ) ); |
| @@ -56,16 +79,8 @@ | ||
| 56 | 79 | add_action( 'wp_enqueue_scripts', array( $this, 'generate_and_enqueue_combined_css' ) ); |
| 57 | 80 | } else { |
| 58 | 81 | add_action( 'wp_footer', array( $this, 'generate_and_enqueue_combined_css' ) ); |
| 59 | 82 | } |
| 60 | - | |
| 61 | - $upload_dir = wp_upload_dir(); | |
| 62 | - $this->upload_dir = $upload_dir['basedir'] . '/gutslider-styles/'; | |
| 63 | - $this->upload_url = $upload_dir['baseurl'] . '/gutslider-styles/'; | |
| 64 | - | |
| 65 | - if ( ! file_exists( $this->upload_dir ) ) { | |
| 66 | - wp_mkdir_p( $this->upload_dir ); | |
| 67 | - } | |
| 68 | 83 | } |
| 69 | 84 | |
| 70 | 85 | /** |
| 71 | 86 | * Collect CSS styles from a rendered block. |
| @@ -70,9 +85,9 @@ | ||
| 70 | 85 | /** |
| 71 | 86 | * Collect CSS styles from a rendered block. |
| 72 | 87 | * |
| 73 | 88 | * Inspects the block's attributes for inline styles and stores |
| 74 | - * them for later combination into a single CSS file. | |
| 89 | + * them for later combination into a single stylesheet. | |
| 75 | 90 | * |
| 76 | 91 | * @since 3.0.0 |
| 77 | 92 | * |
| 78 | 93 | * @param string $block_content The rendered block HTML. |
| @@ -79,9 +94,9 @@ | ||
| 79 | 94 | * @param array<string, mixed> $block The block data array. |
| 80 | 95 | * @return string The unmodified block content. |
| 81 | 96 | */ |
| 82 | 97 | public function collect_block_styles( string $block_content, array $block ): string { |
| 83 | - if ( isset( $block['blockName'] ) && str_contains( $block['blockName'], 'gutsliders/' ) ) { | |
| 98 | + if ( isset( $block['blockName'] ) && str_starts_with( (string) $block['blockName'], 'gutsliders/' ) ) { | |
| 84 | 99 | do_action( 'gutsliders_render_block', $block ); |
| 85 | 100 | |
| 86 | 101 | if ( isset( $block['attrs']['blockStyle'] ) ) { |
| 87 | 102 | $style = $block['attrs']['blockStyle']; |
| @@ -90,9 +105,13 @@ | ||
| 90 | 105 | $style = implode( ' ', $style ); |
| 91 | 106 | } |
| 92 | 107 | |
| 93 | 108 | if ( is_string( $style ) && '' !== $style ) { |
| 94 | - $this->styles[] = wp_strip_all_tags( $style ); | |
| 109 | + $style = $this->sanitize_css( $style ); | |
| 110 | + | |
| 111 | + if ( '' !== $style ) { | |
| 112 | + $this->styles[ md5( $style ) ] = $style; | |
| 113 | + } | |
| 95 | 114 | } |
| 96 | 115 | } |
| 97 | 116 | } |
| 98 | 117 | |
| @@ -99,13 +118,10 @@ | ||
| 99 | 118 | return $block_content; |
| 100 | 119 | } |
| 101 | 120 | |
| 102 | 121 | /** |
| 103 | - * Generate and enqueue the combined CSS file. | |
| 122 | + * Generate and output the combined CSS for the current page. | |
| 104 | 123 | * |
| 105 | - * Combines all collected styles, minifies the result, writes it | |
| 106 | - * to a file in the uploads directory, and enqueues it. | |
| 107 | - * | |
| 108 | 124 | * @since 3.0.0 |
| 109 | 125 | * |
| 110 | 126 | * @return void |
| 111 | 127 | */ |
| @@ -113,53 +129,224 @@ | ||
| 113 | 129 | if ( empty( $this->styles ) ) { |
| 114 | 130 | return; |
| 115 | 131 | } |
| 116 | 132 | |
| 117 | - $combined_css = implode( "\n", $this->styles ); | |
| 118 | - $minified_css = $this->minify_css( $combined_css ); | |
| 133 | + $minified_css = $this->minify_css( implode( "\n", $this->styles ) ); | |
| 119 | 134 | |
| 120 | - $post_id = get_the_ID(); | |
| 121 | - $file_suffix = $post_id ? (string) $post_id : 'archive-' . md5( wp_json_encode( array_keys( $this->styles ) ) ); | |
| 122 | - $css_file_name = 'gutslider-styles-' . $file_suffix . '.min.css'; | |
| 123 | - $css_file_path = $this->upload_dir . $css_file_name; | |
| 124 | - $css_file_url = $this->upload_url . $css_file_name; | |
| 135 | + if ( '' === $minified_css ) { | |
| 136 | + return; | |
| 137 | + } | |
| 125 | 138 | |
| 139 | + /* | |
| 140 | + * Inline output doubles as the fallback: if the stylesheet cannot be | |
| 141 | + * written or enqueued for any reason, print it rather than serving | |
| 142 | + * the page unstyled. | |
| 143 | + */ | |
| 144 | + if ( 'inline' === $this->get_delivery_mode() || ! $this->enqueue_css_file( $minified_css ) ) { | |
| 145 | + $this->print_inline_css( $minified_css ); | |
| 146 | + } | |
| 147 | + } | |
| 148 | + | |
| 149 | + /** | |
| 150 | + * Write the combined CSS to the uploads directory and enqueue it. | |
| 151 | + * | |
| 152 | + * @since 3.0.0 | |
| 153 | + * | |
| 154 | + * @param string $css The minified CSS string. | |
| 155 | + * @return bool True when the stylesheet was enqueued. | |
| 156 | + */ | |
| 157 | + private function enqueue_css_file( string $css ): bool { | |
| 158 | + $filesystem = $this->filesystem(); | |
| 159 | + | |
| 160 | + if ( null === $filesystem || ! $this->prepare_upload_dir( $filesystem ) ) { | |
| 161 | + return false; | |
| 162 | + } | |
| 163 | + | |
| 164 | + /* | |
| 165 | + * Use the queried object rather than get_the_ID(): by the time styles | |
| 166 | + * are output the loop has finished, so the global post still points at | |
| 167 | + * the last post rendered and an archive would otherwise write its | |
| 168 | + * combined CSS over that post's own stylesheet. Anything that is not a | |
| 169 | + * single post gets a content derived name instead, which keeps two | |
| 170 | + * unrelated archives from sharing a file. | |
| 171 | + */ | |
| 172 | + $queried_id = is_singular() ? get_queried_object_id() : 0; | |
| 173 | + $file_suffix = $queried_id ? (string) $queried_id : 'archive-' . md5( $css ); | |
| 174 | + $file_name = 'gutslider-styles-' . $file_suffix . '.min.css'; | |
| 175 | + $file_path = $this->upload_dir . $file_name; | |
| 176 | + | |
| 177 | + $existing = $filesystem->exists( $file_path ) ? $filesystem->get_contents( $file_path ) : false; | |
| 178 | + | |
| 179 | + if ( $existing !== $css && ! $this->write_file( $filesystem, $file_path, $css ) ) { | |
| 180 | + return false; | |
| 181 | + } | |
| 182 | + | |
| 183 | + $version = file_exists( $file_path ) ? filemtime( $file_path ) : false; | |
| 184 | + | |
| 185 | + if ( false === $version ) { | |
| 186 | + return false; | |
| 187 | + } | |
| 188 | + | |
| 189 | + wp_enqueue_style( | |
| 190 | + 'gutslider-combined-styles', | |
| 191 | + $this->upload_url . $file_name, | |
| 192 | + array(), | |
| 193 | + (string) $version | |
| 194 | + ); | |
| 195 | + | |
| 196 | + return true; | |
| 197 | + } | |
| 198 | + | |
| 199 | + /** | |
| 200 | + * Write a file, replacing any existing one atomically. | |
| 201 | + * | |
| 202 | + * The contents go to a temporary file first and are then renamed over the | |
| 203 | + * target, so a concurrent request never reads a half written stylesheet. | |
| 204 | + * | |
| 205 | + * @since 3.0.0 | |
| 206 | + * | |
| 207 | + * @param \WP_Filesystem_Base $filesystem The filesystem abstraction. | |
| 208 | + * @param string $path Absolute destination path. | |
| 209 | + * @param string $contents File contents. | |
| 210 | + * @return bool True on success. | |
| 211 | + */ | |
| 212 | + private function write_file( \WP_Filesystem_Base $filesystem, string $path, string $contents ): bool { | |
| 213 | + $temp_path = $path . '.' . wp_generate_password( 8, false ) . '.tmp'; | |
| 214 | + | |
| 215 | + if ( ! $filesystem->put_contents( $temp_path, $contents, FS_CHMOD_FILE ) ) { | |
| 216 | + $filesystem->delete( $temp_path ); | |
| 217 | + | |
| 218 | + return false; | |
| 219 | + } | |
| 220 | + | |
| 221 | + if ( ! $filesystem->move( $temp_path, $path, true ) ) { | |
| 222 | + $filesystem->delete( $temp_path ); | |
| 223 | + | |
| 224 | + return false; | |
| 225 | + } | |
| 226 | + | |
| 227 | + return true; | |
| 228 | + } | |
| 229 | + | |
| 230 | + /** | |
| 231 | + * Resolve the upload paths and make sure the directory exists. | |
| 232 | + * | |
| 233 | + * @since 3.0.0 | |
| 234 | + * | |
| 235 | + * @param \WP_Filesystem_Base $filesystem The filesystem abstraction. | |
| 236 | + * @return bool True when the directory is available. | |
| 237 | + */ | |
| 238 | + private function prepare_upload_dir( \WP_Filesystem_Base $filesystem ): bool { | |
| 239 | + if ( null === $this->upload_dir ) { | |
| 240 | + $upload_dir = wp_upload_dir(); | |
| 241 | + | |
| 242 | + if ( ! empty( $upload_dir['error'] ) ) { | |
| 243 | + return false; | |
| 244 | + } | |
| 245 | + | |
| 246 | + $this->upload_dir = trailingslashit( $upload_dir['basedir'] ) . 'gutslider-styles/'; | |
| 247 | + $this->upload_url = trailingslashit( $upload_dir['baseurl'] ) . 'gutslider-styles/'; | |
| 248 | + } | |
| 249 | + | |
| 250 | + if ( ! is_dir( $this->upload_dir ) && ! wp_mkdir_p( $this->upload_dir ) ) { | |
| 251 | + return false; | |
| 252 | + } | |
| 253 | + | |
| 254 | + // Keep the directory from being listed on servers with indexes enabled. | |
| 255 | + $index_file = $this->upload_dir . 'index.php'; | |
| 256 | + | |
| 257 | + if ( ! $filesystem->exists( $index_file ) ) { | |
| 258 | + $filesystem->put_contents( $index_file, "<?php\n// Silence is golden.\n", FS_CHMOD_FILE ); | |
| 259 | + } | |
| 260 | + | |
| 261 | + return true; | |
| 262 | + } | |
| 263 | + | |
| 264 | + /** | |
| 265 | + * Get the initialized filesystem abstraction. | |
| 266 | + * | |
| 267 | + * @since 3.0.0 | |
| 268 | + * | |
| 269 | + * @return \WP_Filesystem_Base|null The filesystem, or null when unavailable. | |
| 270 | + */ | |
| 271 | + private function filesystem(): ?\WP_Filesystem_Base { | |
| 126 | 272 | global $wp_filesystem; |
| 127 | 273 | |
| 128 | - if ( empty( $wp_filesystem ) ) { | |
| 274 | + if ( ! $wp_filesystem instanceof \WP_Filesystem_Base ) { | |
| 129 | 275 | require_once ABSPATH . 'wp-admin/includes/file.php'; |
| 130 | 276 | WP_Filesystem(); |
| 131 | 277 | } |
| 132 | 278 | |
| 133 | - $existing_content = $wp_filesystem->exists( $css_file_path ) | |
| 134 | - ? $wp_filesystem->get_contents( $css_file_path ) | |
| 135 | - : ''; | |
| 279 | + return $wp_filesystem instanceof \WP_Filesystem_Base ? $wp_filesystem : null; | |
| 280 | + } | |
| 136 | 281 | |
| 137 | - if ( $existing_content !== $minified_css ) { | |
| 138 | - $wp_filesystem->put_contents( $css_file_path, $minified_css, FS_CHMOD_FILE ); | |
| 282 | + /** | |
| 283 | + * Get the configured CSS delivery mode. | |
| 284 | + * | |
| 285 | + * @since 3.0.0 | |
| 286 | + * | |
| 287 | + * @return string Either 'file' or 'inline'. | |
| 288 | + */ | |
| 289 | + private function get_delivery_mode(): string { | |
| 290 | + $settings = get_option( 'gutslider_settings', array() ); | |
| 291 | + $mode = is_array( $settings ) && isset( $settings['css_delivery'] ) ? $settings['css_delivery'] : 'file'; | |
| 292 | + | |
| 293 | + return 'inline' === $mode ? 'inline' : 'file'; | |
| 294 | + } | |
| 295 | + | |
| 296 | + /** | |
| 297 | + * Print the combined CSS in a style tag instead of a cached file. | |
| 298 | + * | |
| 299 | + * @since 3.0.0 | |
| 300 | + * | |
| 301 | + * @param string $css The minified CSS string. | |
| 302 | + * @return void | |
| 303 | + */ | |
| 304 | + private function print_inline_css( string $css ): void { | |
| 305 | + $handle = 'gutslider-inline-styles'; | |
| 306 | + | |
| 307 | + if ( ! wp_style_is( $handle, 'registered' ) ) { | |
| 308 | + wp_register_style( $handle, false, array(), GUTSLIDER_VERSION ); | |
| 139 | 309 | } |
| 140 | 310 | |
| 141 | - if ( file_exists( $css_file_path ) && is_readable( $css_file_path ) ) { | |
| 142 | - $version = (string) filemtime( $css_file_path ); | |
| 143 | - } else { | |
| 144 | - $version = (string) time(); | |
| 145 | - } | |
| 311 | + wp_enqueue_style( $handle ); | |
| 312 | + wp_add_inline_style( $handle, $css ); | |
| 313 | + } | |
| 146 | 314 | |
| 147 | - if ( file_exists( $css_file_path ) ) { | |
| 148 | - wp_enqueue_style( | |
| 149 | - 'gutslider-combined-styles', | |
| 150 | - $css_file_url, | |
| 151 | - array(), | |
| 152 | - $version | |
| 153 | - ); | |
| 154 | - } | |
| 315 | + /** | |
| 316 | + * Make a block's CSS safe to embed in a style element. | |
| 317 | + * | |
| 318 | + * Only a closing style tag can break out of the surrounding element, so | |
| 319 | + * that is all this removes. A blanket tag strip would also swallow valid | |
| 320 | + * CSS -- `@media (width<600px)` reads as an unclosed tag and would take | |
| 321 | + * the rest of the stylesheet with it. | |
| 322 | + * | |
| 323 | + * Removal repeats until the string stops changing, because a single pass | |
| 324 | + * can reassemble the very sequence it just removed: `<</style/style>` | |
| 325 | + * collapses into `</style>` once the inner match is taken out. Each pass | |
| 326 | + * shortens the string, so this always terminates. | |
| 327 | + * | |
| 328 | + * @since 3.0.0 | |
| 329 | + * | |
| 330 | + * @param string $css The raw CSS string. | |
| 331 | + * @return string The sanitized CSS string. | |
| 332 | + */ | |
| 333 | + private function sanitize_css( string $css ): string { | |
| 334 | + do { | |
| 335 | + $previous = $css; | |
| 336 | + $css = (string) preg_replace( '#</\s*style#i', '', $css ); | |
| 337 | + } while ( $css !== $previous ); | |
| 338 | + | |
| 339 | + return $css; | |
| 155 | 340 | } |
| 156 | 341 | |
| 157 | 342 | /** |
| 158 | 343 | * Minify a CSS string. |
| 159 | 344 | * |
| 160 | - * Removes comments, unnecessary whitespace, and extra spaces | |
| 161 | - * around colons to reduce file size. | |
| 345 | + * Removes comments, collapses runs of whitespace, and drops the spaces | |
| 346 | + * surrounding characters that never need them. Whitespace that separates | |
| 347 | + * two values is preserved as a single space, so multi-line declarations | |
| 348 | + * such as `grid-template-areas` survive intact. | |
| 162 | 349 | * |
| 163 | 350 | * @since 3.0.0 |
| 164 | 351 | * |
| 165 | 352 | * @param string $css The raw CSS string. |
| @@ -168,13 +355,48 @@ | ||
| 168 | 355 | private function minify_css( string $css ): string { |
| 169 | 356 | // Remove comments. |
| 170 | 357 | $css = (string) preg_replace( '!/\*[^*]*\*+([^/][^*]*\*+)*/!', '', $css ); |
| 171 | 358 | |
| 172 | - // Remove space after colons. | |
| 173 | - $css = str_replace( ': ', ':', $css ); | |
| 359 | + /* | |
| 360 | + * Set quoted strings aside so their whitespace is not collapsed, then | |
| 361 | + * restore them once the surrounding CSS has been minified. | |
| 362 | + */ | |
| 363 | + $strings = array(); | |
| 174 | 364 | |
| 175 | - // Remove whitespace. | |
| 176 | - $css = str_replace( array( "\r\n", "\r", "\n", "\t", ' ', ' ', ' ' ), '', $css ); | |
| 365 | + $css = (string) preg_replace_callback( | |
| 366 | + '/"(?:[^"\\\\]|\\\\.)*"|\'(?:[^\'\\\\]|\\\\.)*\'/', | |
| 367 | + static function ( array $match ) use ( &$strings ): string { | |
| 368 | + $strings[] = $match[0]; | |
| 369 | + | |
| 370 | + return "\0gs" . ( count( $strings ) - 1 ) . "\0"; | |
| 371 | + }, | |
| 372 | + $css | |
| 373 | + ); | |
| 374 | + | |
| 375 | + // Collapse every run of whitespace into a single space. | |
| 376 | + $css = (string) preg_replace( '/\s+/', ' ', $css ); | |
| 377 | + | |
| 378 | + /* | |
| 379 | + * Drop the spaces around structural characters. Combinators such as | |
| 380 | + * `+` and `~` are left alone because they also appear inside values | |
| 381 | + * like `calc()` and `nth-child()`, as is `(`, which needs its leading | |
| 382 | + * space in `@media screen and (min-width: 600px)`. | |
| 383 | + */ | |
| 384 | + $css = (string) preg_replace( '/\s*([{}:;,>])\s*/', '$1', $css ); | |
| 385 | + | |
| 386 | + // Drop the final semicolon of each rule. | |
| 387 | + $css = str_replace( ';}', '}', $css ); | |
| 388 | + $css = trim( $css ); | |
| 389 | + | |
| 390 | + if ( ! empty( $strings ) ) { | |
| 391 | + $css = (string) preg_replace_callback( | |
| 392 | + "/\0gs(\d+)\0/", | |
| 393 | + static function ( array $match ) use ( $strings ): string { | |
| 394 | + return $strings[ (int) $match[1] ]; | |
| 395 | + }, | |
| 396 | + $css | |
| 397 | + ); | |
| 398 | + } | |
| 177 | 399 | |
| 178 | 400 | return $css; |
| 179 | 401 | } |
| 180 | 402 | } |