PluginProbe
aBlocks – Gutenberg Blocks, User Dashboard Builder, Popup Builder, Form Builder & Animation Builder / trunk
aBlocks – Gutenberg Blocks, User Dashboard Builder, Popup Builder, Form Builder & Animation Builder vtrunk
2.12.0 2.11.1 2.11.0 2.10.0 2.9.0 2.7.4 2.7.5 2.7.6 2.7.7 2.8.0 2.8.1 2.9.1 trunk 1.0 1.0-beta1 1.0-beta2 1.0-beta3 1.0.1 1.0.2 1.0.3 1.1.0 1.1.1 1.1.2 1.2.0 1.2.1 All 78 releases
ablocks / includes / performance / style-consolidator.php

style-consolidator.php in aBlocks – Gutenberg Blocks, User Dashboard Builder, Popup Builder, Form Builder & Animation Builder trunk, at includes/performance/style-consolidator.php

324 lines 9.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 namespace ABlocks\Performance;
3
4 if ( ! defined( 'ABSPATH' ) ) {
5 exit;
6 }
7
8 use ABlocks\Helper;
9 use ABlocks\Classes\FileUpload;
10
11 /**
12 * Performance Suite — consolidate render-blocking inline CSS into cached files.
13 *
14 * Block themes emit a large amount of inline CSS into <head>: global styles,
15 * the block library, and one <style> per core block used on the page. Measured
16 * on Twenty Twenty-Four, that is ~43 KB across 20 blocks — around 40% of the
17 * document — and none of it can be reused by the browser across navigations,
18 * because it is embedded in the HTML rather than fetched as a file.
19 *
20 * This moves those blocks into content-addressed files under uploads so the
21 * browser caches them once, and every cached HTML page on disk shrinks by the
22 * same amount.
23 *
24 * ## Why per contiguous run, and not one bundle
25 *
26 * WordPress prints each stylesheet's <link> followed by its own inline <style>,
27 * so the inline blocks are *interleaved* with <link> tags — on the reference
28 * page they form 7 separate runs, not one. Concatenating all of them into a
29 * single file would move CSS across those <link> boundaries and silently
30 * reorder the cascade, which is exactly the class of bug that is impossible to
31 * attribute later.
32 *
33 * So each maximal run of adjacent <style> blocks is replaced, in place, by one
34 * <link> to a file containing exactly that run's CSS in its original order. The
35 * resulting cascade is byte-for-byte equivalent to what WordPress emitted.
36 *
37 * Runs below a byte threshold are left inline: below roughly a couple of KB a
38 * separate request costs more than the bytes saved, which is the same reasoning
39 * behind the existing `perf_inline_css` option.
40 *
41 * File names are a hash of their contents, so they are immutable and never need
42 * invalidating — a change in the CSS simply produces a different file. See
43 * docs/PAGE-CACHE-PLAN.md.
44 */
45 class StyleConsolidator {
46
47 const DIR_NAME = 'consolidated-css';
48
49 /**
50 * Default minimum run size, in bytes, worth externalising.
51 */
52 const DEFAULT_MIN_BYTES = 2048;
53
54 public static function init() {
55 if ( is_admin() ) {
56 return;
57 }
58
59 $enabled = (bool) apply_filters(
60 'ablocks/perf/perf_consolidate_css',
61 (bool) Helper::get_settings( 'perf_consolidate_css', false )
62 );
63 if ( ! $enabled ) {
64 return;
65 }
66
67 // Never reshape the document for a logged-in editor previewing the
68 // frontend — consistent with the rest of the Performance Suite.
69 if ( is_user_logged_in() && current_user_can( 'edit_posts' )
70 && (bool) apply_filters( 'ablocks/perf/bypass_optimizations_for_editors', true ) ) {
71 return;
72 }
73
74 $self = new self();
75
76 // Priority 1 puts this buffer *inside* PageCache's (priority 0). Nested
77 // buffers flush inner-first, so consolidation happens before the page
78 // cache sees the output — meaning what gets written to disk is the
79 // already-consolidated document, not the original.
80 add_action( 'template_redirect', [ $self, 'start_buffer' ], 1 );
81 }
82
83 /**
84 * Begin capturing output.
85 */
86 public function start_buffer() {
87 if ( is_feed() || is_robots() || is_trackback() ) {
88 return;
89 }
90 ob_start( [ $this, 'process' ] );
91 }
92
93 /**
94 * Rewrite the document head, replacing large runs of inline CSS with links.
95 *
96 * Must never throw and must always return usable HTML: a failure here should
97 * cost the optimisation, not the page.
98 *
99 * @param string $html Buffered output.
100 * @return string
101 */
102 public function process( $html ) {
103 if ( ! is_string( $html ) || '' === $html ) {
104 return $html;
105 }
106
107 try {
108 $head_end = stripos( $html, '</head>' );
109 if ( false === $head_end ) {
110 return $html;
111 }
112
113 $head = substr( $html, 0, $head_end );
114 $rest = substr( $html, $head_end );
115
116 $runs = $this->find_runs( $head );
117 if ( empty( $runs ) ) {
118 return $html;
119 }
120
121 $min = (int) apply_filters(
122 'ablocks/perf/consolidate_css/min_bytes',
123 (int) Helper::get_settings( 'perf_consolidate_css_min', self::DEFAULT_MIN_BYTES )
124 );
125
126 // Replace from the end backwards so earlier offsets stay valid.
127 foreach ( array_reverse( $runs ) as $run ) {
128 if ( strlen( $run['css'] ) < $min ) {
129 continue;
130 }
131 $url = $this->store_css( $run['css'] );
132 if ( null === $url ) {
133 continue;
134 }
135 $tag = '<link rel="stylesheet" href="' . esc_url( $url ) . '" media="all" />';
136 $head = substr_replace( $head, $tag, $run['start'], $run['end'] - $run['start'] );
137 }
138
139 return $head . $rest;
140 } catch ( \Throwable $e ) {
141 if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
142 error_log( 'aBlocks style consolidator: ' . $e->getMessage() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
143 }
144 return $html;
145 }//end try
146 }
147
148 /**
149 * Locate maximal runs of adjacent <style> blocks within the head.
150 *
151 * Two blocks belong to the same run only when nothing but whitespace sits
152 * between them. Anything else — most importantly a <link> — ends the run,
153 * because merging across it would change the cascade.
154 *
155 * @param string $head Document head.
156 * @return array<int, array{start:int, end:int, css:string}>
157 */
158 private function find_runs( $head ) {
159 // Only tags carrying an id are considered: those are the ones WordPress
160 // generates from wp_add_inline_style(). A bare <style> is more likely to
161 // be hand-written critical CSS that a theme placed deliberately, and
162 // moving it into a file would defeat its purpose.
163 $pattern = '#<style[^>]*\bid=["\']([^"\']+)["\'][^>]*>(.*?)</style>#is';
164 if ( ! preg_match_all( $pattern, $head, $matches, PREG_OFFSET_CAPTURE | PREG_SET_ORDER ) ) {
165 return [];
166 }
167
168 $skip = (array) apply_filters( 'ablocks/perf/consolidate_css/skip_handles', [] );
169
170 $runs = [];
171 $current = null;
172 $prev_end = null;
173
174 foreach ( $matches as $match ) {
175 $tag = $match[0][0];
176 $start = $match[0][1];
177 $end = $start + strlen( $tag );
178 $handle = $match[1][0];
179 $css = $match[2][0];
180
181 $breaks_run = false;
182
183 // A media query other than "all" changes when the CSS applies; it
184 // cannot be folded in with unconditional rules.
185 if ( preg_match( '#\bmedia=["\']([^"\']+)["\']#i', $tag, $media ) && 'all' !== strtolower( trim( $media[1] ) ) ) {
186 $breaks_run = true;
187 }
188 if ( in_array( $handle, $skip, true ) ) {
189 $breaks_run = true;
190 }
191
192 $adjacent = ( null !== $prev_end && '' === trim( substr( $head, $prev_end, $start - $prev_end ) ) );
193
194 if ( $breaks_run ) {
195 if ( null !== $current ) {
196 $runs[] = $current;
197 $current = null;
198 }
199 $prev_end = $end;
200 continue;
201 }
202
203 if ( null === $current || ! $adjacent ) {
204 if ( null !== $current ) {
205 $runs[] = $current;
206 }
207 $current = [
208 'start' => $start,
209 'end' => $end,
210 'css' => $css,
211 ];
212 } else {
213 $current['end'] = $end;
214 $current['css'] .= "\n" . $css;
215 }
216
217 $prev_end = $end;
218 }//end foreach
219
220 if ( null !== $current ) {
221 $runs[] = $current;
222 }
223
224 return $runs;
225 }
226
227 /**
228 * Write CSS to a content-addressed file and return its URL.
229 *
230 * The name is a hash of the contents, so the file is immutable: changed CSS
231 * yields a different name rather than a stale file, and no invalidation
232 * hook is needed anywhere.
233 *
234 * @param string $css Stylesheet contents.
235 * @return string|null Public URL, or null on failure.
236 */
237 private function store_css( $css ) {
238 $hash = substr( sha1( $css ), 0, 20 );
239 $dir = self::base_dir();
240 $file = $dir . '/' . $hash . '.css';
241
242 if ( ! file_exists( $file ) ) {
243 if ( ! is_dir( $dir ) && ! wp_mkdir_p( $dir ) ) {
244 return null;
245 }
246 self::protect_dir();
247
248 // Written through the page-cache store so both features share one
249 // atomic write-then-rename implementation.
250 if ( ! \ABlocks\Classes\PageCache\Store::put_file( $file, $css ) ) {
251 return null;
252 }
253 }
254
255 return self::base_url() . '/' . $hash . '.css';
256 }
257
258 /**
259 * Absolute path to the consolidated CSS directory.
260 *
261 * @return string
262 */
263 public static function base_dir() {
264 $upload = new FileUpload();
265 return untrailingslashit( $upload->get_upload_dir() ) . '/' . self::DIR_NAME;
266 }
267
268 /**
269 * Public URL of the consolidated CSS directory.
270 *
271 * @return string
272 */
273 public static function base_url() {
274 $upload = new FileUpload();
275 return untrailingslashit( $upload->get_upload_url() ) . '/' . self::DIR_NAME;
276 }
277
278 /**
279 * Drop an index.php guard so the directory cannot be browsed.
280 */
281 private static function protect_dir() {
282 $index = self::base_dir() . '/index.php';
283 if ( ! file_exists( $index ) ) {
284 \ABlocks\Classes\PageCache\Store::put_file( $index, "<?php\n// Silence is golden.\n" );
285 }
286 }
287
288 /**
289 * Delete consolidated files untouched for longer than $days.
290 *
291 * Files are content-addressed and referenced by cached HTML, so they must
292 * never be purged alongside the page cache — doing so would leave cached
293 * pages pointing at stylesheets that no longer exist. Age-based collection
294 * is the safe way to reclaim space.
295 *
296 * @param int $days Age threshold.
297 * @return int Files removed.
298 */
299 public static function prune( $days = 30 ) {
300 $dir = self::base_dir();
301 if ( ! is_dir( $dir ) ) {
302 return 0;
303 }
304 $cutoff = time() - ( max( 1, (int) $days ) * DAY_IN_SECONDS );
305 $removed = 0;
306 // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Return value is checked; a directory vanishing mid-sweep is normal.
307 $entries = @scandir( $dir );
308 if ( false === $entries ) {
309 return 0;
310 }
311 foreach ( $entries as $entry ) {
312 if ( '.css' !== substr( $entry, -4 ) ) {
313 continue;
314 }
315 $path = $dir . '/' . $entry;
316 if ( filemtime( $path ) < $cutoff ) {
317 wp_delete_file( $path );
318 $removed++;
319 }
320 }
321 return $removed;
322 }
323 }
324