PluginProbe
aBlocks – Gutenberg Blocks, User Dashboard Builder, Popup Builder, Form Builder & Animation Builder / 2.11.0
aBlocks – Gutenberg Blocks, User Dashboard Builder, Popup Builder, Form Builder & Animation Builder v2.11.0
2.13.0 2.13.1 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 All 80 releases
ablocks / includes / performance / image-optimizer.php

image-optimizer.php in aBlocks – Gutenberg Blocks, User Dashboard Builder, Popup Builder, Form Builder & Animation Builder 2.11.0, at includes/performance/image-optimizer.php

251 lines 8.0 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
10 /**
11 * Performance Suite — image loading optimizations for aBlocks blocks.
12 *
13 * Two independent, opt-in transforms applied to rendered aBlocks block HTML:
14 * - `perf_lazy_images`: `loading="lazy"` + `decoding="async"`, keeping the first
15 * N images eager with `fetchpriority="high"` so the LCP image isn't deferred.
16 * - `perf_image_dimensions`: inject intrinsic `width`/`height` on images that
17 * have neither, so the browser reserves space and Cumulative Layout Shift
18 * (CLS) drops. Dimensions are resolved cheaply — from the `wp-image-{id}`
19 * class (attachment metadata) or the WordPress `-WIDTHxHEIGHT` filename
20 * suffix — with no per-request filesystem reads.
21 *
22 * Operates via the `render_block` filter (scoped to aBlocks blocks) so no
23 * per-block markup changes are needed.
24 */
25 class ImageOptimizer {
26
27 private $image_index = 0;
28 private $eager_count = 1;
29 private $do_lazy = false;
30 private $do_dimensions = false;
31 private $do_responsive = false;
32
33 public static function init() {
34 if ( is_admin() ) {
35 return;
36 }
37 $self = new self();
38 $self->do_lazy = (bool) apply_filters(
39 'ablocks/perf/perf_lazy_images',
40 (bool) Helper::get_settings( 'perf_lazy_images', true )
41 );
42 $self->do_dimensions = (bool) apply_filters(
43 'ablocks/perf/perf_image_dimensions',
44 (bool) Helper::get_settings( 'perf_image_dimensions', true )
45 );
46 $self->do_responsive = (bool) apply_filters(
47 'ablocks/perf/perf_responsive_images',
48 (bool) Helper::get_settings( 'perf_responsive_images', true )
49 );
50 if ( ! $self->do_lazy && ! $self->do_dimensions && ! $self->do_responsive ) {
51 return;
52 }
53 $self->eager_count = (int) apply_filters(
54 'ablocks/perf/lcp_eager_count',
55 (int) Helper::get_settings( 'perf_lcp_eager_count', 1 )
56 );
57 add_filter( 'render_block', [ $self, 'process' ], 20, 2 );
58 }
59
60 public function process( $content, $block ) {
61 if ( empty( $block['blockName'] ) || false === strpos( $block['blockName'], 'ablocks' ) ) {
62 return $content;
63 }
64 if ( false === strpos( $content, '<img' ) ) {
65 return $content;
66 }
67
68 // aBlocks image blocks store the attachment id in their attributes but
69 // don't emit a wp-image-{id} class, and URL→id lookups fail for
70 // intermediate sizes of -scaled images. Use the block's own id as the
71 // authoritative source for its image.
72 $attrs = isset( $block['attrs'] ) ? $block['attrs'] : [];
73 $this->block_image_id = 0;
74 foreach ( [ 'imgId', 'imgIdMobile', 'imgIdTablet' ] as $key ) {
75 if ( ! empty( $attrs[ $key ] ) ) {
76 $this->block_image_id = (int) $attrs[ $key ];
77 break;
78 }
79 }
80
81 return preg_replace_callback(
82 '/<img\b[^>]*>/i',
83 [ $this, 'rewrite_img' ],
84 $content
85 );
86 }
87
88 private function rewrite_img( $matches ) {
89 $tag = $matches[0];
90 $this->image_index++;
91 $attrs = '';
92
93 // Lazy-loading / priority hints.
94 if ( $this->do_lazy ) {
95 $has_loading = false !== stripos( $tag, 'loading=' );
96 $is_eager = $this->image_index <= $this->eager_count;
97
98 if ( ! $has_loading ) {
99 // No loading hint yet — add one (idempotent, never overrides markup
100 // that set its own).
101 $attrs .= $is_eager ? ' loading="eager" fetchpriority="high"' : ' loading="lazy"';
102 if ( false === stripos( $tag, 'decoding=' ) ) {
103 $attrs .= ' decoding="async"';
104 }
105 } elseif ( $is_eager && false === stripos( $tag, 'fetchpriority=' ) ) {
106 // Above-the-fold image that markup hardcoded as loading="lazy" (e.g.
107 // aBlocks image save output) — upgrade it to eager + high priority so
108 // the likely-LCP image isn't deferred.
109 $tag = preg_replace(
110 '/\bloading=(["\'])(?:lazy|auto)\1/i',
111 'loading="eager" fetchpriority="high"',
112 $tag,
113 1
114 );
115 }
116 }
117
118 // CLS fix — reserve space by giving images that have neither width nor
119 // height their intrinsic dimensions.
120 if ( $this->do_dimensions
121 && false === stripos( $tag, 'width=' )
122 && false === stripos( $tag, 'height=' ) ) {
123 $dim = $this->resolve_dimensions( $tag );
124 if ( $dim ) {
125 $attrs .= ' width="' . (int) $dim[0] . '" height="' . (int) $dim[1] . '"';
126 }
127 }
128
129 // Responsive delivery — add a width-descriptor srcset + sizes so the
130 // browser downloads a right-sized file instead of the full image (the
131 // single biggest mobile payload win). Only when the image maps to an
132 // attachment and doesn't already declare srcset.
133 if ( $this->do_responsive && false === stripos( $tag, 'srcset=' ) ) {
134 $attrs .= $this->responsive_attrs( $tag );
135 }
136
137 if ( '' === $attrs ) {
138 return $tag;
139 }
140 return preg_replace( '/^<img\b/', '<img' . $attrs, $tag, 1 );
141 }
142
143 /**
144 * Build ` srcset="…" sizes="…"` for an image that maps to an attachment,
145 * using WordPress core's generators (which read the already-stored metadata,
146 * no filesystem work). Returns '' when the image can't be mapped or has no
147 * alternate sizes.
148 */
149 private function responsive_attrs( $tag ) {
150 $id = $this->resolve_attachment_id( $tag );
151 if ( ! $id ) {
152 return '';
153 }
154 $size = $this->resolve_dimensions( $tag );
155 $size = $size ? $size : 'full';
156
157 $srcset = wp_get_attachment_image_srcset( $id, $size );
158 if ( ! $srcset ) {
159 return '';
160 }
161 $sizes = wp_get_attachment_image_sizes( $id, $size );
162 $out = ' srcset="' . esc_attr( $srcset ) . '"';
163 if ( $sizes && false === stripos( $tag, 'sizes=' ) ) {
164 $out .= ' sizes="' . esc_attr( $sizes ) . '"';
165 }
166 return $out;
167 }
168
169 /**
170 * Resolve an image's intrinsic [width, height] without touching the
171 * filesystem: first from the attachment metadata (via the wp-image-{id}
172 * class, matching the exact rendered size), then from WordPress's
173 * `-WIDTHxHEIGHT` resized-filename convention. Returns null when unknown.
174 */
175 private function resolve_dimensions( $tag ) {
176 $src = $this->get_attr( $tag, 'src' );
177
178 $id = $this->resolve_attachment_id( $tag );
179 if ( $id ) {
180 $dim = $this->dimensions_from_attachment( $id, $src );
181 if ( $dim ) {
182 return $dim;
183 }
184 }
185
186 if ( $src && preg_match( '/-(\d+)x(\d+)\.(?:jpe?g|png|gif|webp|avif|bmp)(?:\?.*)?$/i', $src, $m ) ) {
187 return [ (int) $m[1], (int) $m[2] ];
188 }
189
190 return null;
191 }
192
193 /**
194 * Resolve the attachment id for an <img>: prefer the WordPress `wp-image-{id}`
195 * class, otherwise map the src URL back to an attachment (aBlocks image blocks
196 * don't emit the class). URL lookups are cached per request — and only run
197 * when the class is absent — so the DB is hit at most once per unique URL.
198 */
199 private $id_cache = [];
200 private $block_image_id = 0;
201 private function resolve_attachment_id( $tag ) {
202 if ( preg_match( '/wp-image-(\d+)/', $tag, $m ) ) {
203 return (int) $m[1];
204 }
205 // The current block's stored attachment id (authoritative for aBlocks
206 // image blocks, which don't emit the class).
207 if ( $this->block_image_id ) {
208 return $this->block_image_id;
209 }
210 $src = $this->get_attr( $tag, 'src' );
211 if ( ! $src ) {
212 return 0;
213 }
214 if ( ! array_key_exists( $src, $this->id_cache ) ) {
215 $this->id_cache[ $src ] = (int) attachment_url_to_postid( $src );
216 }
217 return $this->id_cache[ $src ];
218 }
219
220 /**
221 * Pull width/height from an attachment's stored metadata, preferring the
222 * registered sub-size whose file matches the rendered src, falling back to
223 * the full-size dimensions.
224 */
225 private function dimensions_from_attachment( $id, $src ) {
226 if ( ! $id ) {
227 return null;
228 }
229 $meta = wp_get_attachment_metadata( $id );
230 if ( ! is_array( $meta ) || empty( $meta['width'] ) || empty( $meta['height'] ) ) {
231 return null;
232 }
233 if ( $src && ! empty( $meta['sizes'] ) && is_array( $meta['sizes'] ) ) {
234 $base = wp_basename( strtok( $src, '?' ) );
235 foreach ( $meta['sizes'] as $size ) {
236 if ( isset( $size['file'], $size['width'], $size['height'] ) && $size['file'] === $base ) {
237 return [ (int) $size['width'], (int) $size['height'] ];
238 }
239 }
240 }
241 return [ (int) $meta['width'], (int) $meta['height'] ];
242 }
243
244 private function get_attr( $tag, $name ) {
245 if ( preg_match( '/\b' . preg_quote( $name, '/' ) . '=(["\'])(.*?)\1/i', $tag, $m ) ) {
246 return $m[2];
247 }
248 return '';
249 }
250 }
251