PluginProbe
GutSlider – All in One Slider and Carousel Blocks for Gutenberg / 3.0.0
GutSlider – All in One Slider and Carousel Blocks for Gutenberg v3.0.0
3.1.0 3.0.0 2.13.2 2.13.1 2.13.0 trunk 1.0.0 2.1.0 2.10.0 2.10.1 2.11.0 2.11.1 2.11.2 2.11.3 2.11.4 2.12.0 2.2.1 2.2.2 2.3.0 2.4.0 2.5.1 2.5.3 2.5.4 2.5.5 2.6.1 All 56 releases
slider-blocks / includes / Style / DynamicStyle.php

DynamicStyle.php in GutSlider – All in One Slider and Carousel Blocks for Gutenberg 3.0.0, at includes/Style/DynamicStyle.php

403 lines 11.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 declare( strict_types=1 );
3
4 namespace GutSlider\Style;
5
6 if ( ! defined( 'ABSPATH' ) ) {
7 exit;
8 }
9
10 /**
11 * Generates and enqueues dynamic CSS for rendered slider blocks.
12 *
13 * Collects block-level styles during rendering, combines and minifies
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.
18 *
19 * @package GutSlider\Style
20 * @since 3.0.0
21 */
22 final class DynamicStyle {
23
24 /**
25 * Collected CSS styles from rendered blocks.
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 *
30 * @since 3.0.0
31 * @var array<string, string>
32 */
33 private array $styles = array();
34
35 /**
36 * Absolute path to the CSS upload directory, or null until resolved.
37 *
38 * @since 3.0.0
39 * @var string|null
40 */
41 private ?string $upload_dir = null;
42
43 /**
44 * Public URL to the CSS upload directory, or null until resolved.
45 *
46 * @since 3.0.0
47 * @var string|null
48 */
49 private ?string $upload_url = null;
50
51 /**
52 * Constructor.
53 *
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.
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 *
67 * @since 3.0.0
68 *
69 * @return void
70 */
71 public function register_render_hooks(): void {
72 if ( is_admin() || is_feed() ) {
73 return;
74 }
75
76 add_filter( 'render_block', array( $this, 'collect_block_styles' ), 10, 2 );
77
78 if ( wp_is_block_theme() ) {
79 add_action( 'wp_enqueue_scripts', array( $this, 'generate_and_enqueue_combined_css' ) );
80 } else {
81 add_action( 'wp_footer', array( $this, 'generate_and_enqueue_combined_css' ) );
82 }
83 }
84
85 /**
86 * Collect CSS styles from a rendered block.
87 *
88 * Inspects the block's attributes for inline styles and stores
89 * them for later combination into a single stylesheet.
90 *
91 * @since 3.0.0
92 *
93 * @param string $block_content The rendered block HTML.
94 * @param array<string, mixed> $block The block data array.
95 * @return string The unmodified block content.
96 */
97 public function collect_block_styles( string $block_content, array $block ): string {
98 if ( isset( $block['blockName'] ) && str_starts_with( (string) $block['blockName'], 'gutsliders/' ) ) {
99 do_action( 'gutsliders_render_block', $block );
100
101 if ( isset( $block['attrs']['blockStyle'] ) ) {
102 $style = $block['attrs']['blockStyle'];
103
104 if ( is_array( $style ) && ! empty( $style ) ) {
105 $style = implode( ' ', $style );
106 }
107
108 if ( is_string( $style ) && '' !== $style ) {
109 $style = $this->sanitize_css( $style );
110
111 if ( '' !== $style ) {
112 $this->styles[ md5( $style ) ] = $style;
113 }
114 }
115 }
116 }
117
118 return $block_content;
119 }
120
121 /**
122 * Generate and output the combined CSS for the current page.
123 *
124 * @since 3.0.0
125 *
126 * @return void
127 */
128 public function generate_and_enqueue_combined_css(): void {
129 if ( empty( $this->styles ) ) {
130 return;
131 }
132
133 $minified_css = $this->minify_css( implode( "\n", $this->styles ) );
134
135 if ( '' === $minified_css ) {
136 return;
137 }
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 {
272 global $wp_filesystem;
273
274 if ( ! $wp_filesystem instanceof \WP_Filesystem_Base ) {
275 require_once ABSPATH . 'wp-admin/includes/file.php';
276 WP_Filesystem();
277 }
278
279 return $wp_filesystem instanceof \WP_Filesystem_Base ? $wp_filesystem : null;
280 }
281
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 );
309 }
310
311 wp_enqueue_style( $handle );
312 wp_add_inline_style( $handle, $css );
313 }
314
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;
340 }
341
342 /**
343 * Minify a CSS string.
344 *
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.
349 *
350 * @since 3.0.0
351 *
352 * @param string $css The raw CSS string.
353 * @return string The minified CSS string.
354 */
355 private function minify_css( string $css ): string {
356 // Remove comments.
357 $css = (string) preg_replace( '!/\*[^*]*\*+([^/][^*]*\*+)*/!', '', $css );
358
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();
364
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 }
399
400 return $css;
401 }
402 }
403