PluginProbe
GutSlider – All in One Slider and Carousel Blocks for Gutenberg / 3.2.0
GutSlider – All in One Slider and Carousel Blocks for Gutenberg v3.2.0
3.2.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 All 57 releases
← All changes | includes/Style/DynamicStyle.php +279 -57 2.13.0 → 3.2.0 View file →
@@ -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 }