PluginProbe
aBlocks – Gutenberg Blocks, User Dashboard Builder, Popup Builder, Form Builder & Animation Builder / 2.13.1
aBlocks – Gutenberg Blocks, User Dashboard Builder, Popup Builder, Form Builder & Animation Builder v2.13.1
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
← All changes | includes/performance/image-optimizer.php +213 -15 2.10.02.13.1 View file →
@@ -9,31 +9,48 @@
9 9
10 10 /**
11 11 * Performance Suite — image loading optimizations for aBlocks blocks.
12 12 *
13 - * Adds `loading="lazy"` + `decoding="async"` to images, while keeping the first
14 - * N images eager with `fetchpriority="high"` so the LCP image isn't deferred.
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.
15 21 *
16 - * Opt-in via `perf_lazy_images`. Operates on rendered block HTML (scoped to
17 - * aBlocks blocks) so no per-block markup changes are needed.
22 + * Operates via the `render_block` filter (scoped to aBlocks blocks) so no
23 + * per-block markup changes are needed.
18 24 */
19 25 class ImageOptimizer {
20 26
21 27 private $image_index = 0;
22 28 private $eager_count = 1;
29 + private $do_lazy = false;
30 + private $do_dimensions = false;
31 + private $do_responsive = false;
23 32
24 33 public static function init() {
25 34 if ( is_admin() ) {
26 35 return;
27 36 }
28 - $enabled = (bool) apply_filters(
37 + $self = new self();
38 + $self->do_lazy = (bool) apply_filters(
29 39 'ablocks/perf/perf_lazy_images',
30 40 (bool) Helper::get_settings( 'perf_lazy_images', true )
31 41 );
32 - if ( ! $enabled ) {
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 ) {
33 51 return;
34 52 }
35 - $self = new self();
36 53 $self->eager_count = (int) apply_filters(
37 54 'ablocks/perf/lcp_eager_count',
38 55 (int) Helper::get_settings( 'perf_lcp_eager_count', 1 )
39 56 );
@@ -47,10 +64,23 @@
47 64 if ( false === strpos( $content, '<img' ) ) {
48 65 return $content;
49 66 }
50 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 +
51 81 return preg_replace_callback(
52 - '/<img\b(?![^>]*\bloading=)[^>]*>/i',
82 + '/<img\b[^>]*>/i',
53 83 [ $this, 'rewrite_img' ],
54 84 $content
55 85 );
56 86 }
@@ -57,19 +87,187 @@
57 87
58 88 private function rewrite_img( $matches ) {
59 89 $tag = $matches[0];
60 90 $this->image_index++;
91 + $attrs = '';
61 92
62 - if ( $this->image_index <= $this->eager_count ) {
63 - // Likely-LCP images: load eagerly with high priority.
64 - $attrs = ' loading="eager" fetchpriority="high"';
65 - } else {
66 - $attrs = ' loading="lazy"';
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 + }
67 116 }
68 117
69 - if ( false === stripos( $tag, 'decoding=' ) ) {
70 - $attrs .= ' decoding="async"';
118 + // CLS fix — make sure the image always carries BOTH width and height so the
119 + // browser has an intrinsic aspect ratio. A missing dimension is as harmful as
120 + // missing both: an <img> with width but no height (e.g. a block with a custom
121 + // width) has no aspect ratio, so once core's "sizes=auto" is added the
122 + // `contain-intrinsic-size:3000px 1500px` fallback stretches it tall. When only
123 + // one dimension is present we derive the other from the intrinsic ratio rather
124 + // than skipping. This runs when the dimensions feature is on OR responsive
125 + // images are on — adding a srcset is what triggers "sizes=auto", so any image
126 + // that gets a srcset must also carry both dimensions.
127 + if ( $this->do_dimensions || $this->do_responsive ) {
128 + $has_w = false !== stripos( $tag, 'width=' );
129 + $has_h = false !== stripos( $tag, 'height=' );
130 + if ( ! $has_w || ! $has_h ) {
131 + $dim = $this->resolve_dimensions( $tag );
132 + if ( $dim && $dim[0] > 0 && $dim[1] > 0 ) {
133 + if ( ! $has_w && ! $has_h ) {
134 + $attrs .= ' width="' . (int) $dim[0] . '" height="' . (int) $dim[1] . '"';
135 + } elseif ( $has_w ) {
136 + // Width present, height missing → height = width × (natH / natW).
137 + $w = $this->get_attr( $tag, 'width' );
138 + if ( preg_match( '/^\d+$/', $w ) && (int) $w > 0 ) {
139 + $attrs .= ' height="' . (int) round( (int) $w * $dim[1] / $dim[0] ) . '"';
140 + }
141 + } else {
142 + // Height present, width missing → width = height × (natW / natH).
143 + $h = $this->get_attr( $tag, 'height' );
144 + if ( preg_match( '/^\d+$/', $h ) && (int) $h > 0 ) {
145 + $attrs .= ' width="' . (int) round( (int) $h * $dim[0] / $dim[1] ) . '"';
146 + }
147 + }
148 + }
149 + }
71 150 }
72 151
152 + // Responsive delivery — add a width-descriptor srcset + sizes so the
153 + // browser downloads a right-sized file instead of the full image (the
154 + // single biggest mobile payload win). Only when the image maps to an
155 + // attachment and doesn't already declare srcset.
156 + if ( $this->do_responsive && false === stripos( $tag, 'srcset=' ) ) {
157 + $attrs .= $this->responsive_attrs( $tag );
158 + }
159 +
160 + if ( '' === $attrs ) {
161 + return $tag;
162 + }
73 163 return preg_replace( '/^<img\b/', '<img' . $attrs, $tag, 1 );
164 + }
165 +
166 + /**
167 + * Build ` srcset="…" sizes="…"` for an image that maps to an attachment,
168 + * using WordPress core's generators (which read the already-stored metadata,
169 + * no filesystem work). Returns '' when the image can't be mapped or has no
170 + * alternate sizes.
171 + */
172 + private function responsive_attrs( $tag ) {
173 + $id = $this->resolve_attachment_id( $tag );
174 + if ( ! $id ) {
175 + return '';
176 + }
177 + $size = $this->resolve_dimensions( $tag );
178 + $size = $size ? $size : 'full';
179 +
180 + $srcset = wp_get_attachment_image_srcset( $id, $size );
181 + if ( ! $srcset ) {
182 + return '';
183 + }
184 + $sizes = wp_get_attachment_image_sizes( $id, $size );
185 + $out = ' srcset="' . esc_attr( $srcset ) . '"';
186 + if ( $sizes && false === stripos( $tag, 'sizes=' ) ) {
187 + $out .= ' sizes="' . esc_attr( $sizes ) . '"';
188 + }
189 + return $out;
190 + }
191 +
192 + /**
193 + * Resolve an image's intrinsic [width, height] without touching the
194 + * filesystem: first from the attachment metadata (via the wp-image-{id}
195 + * class, matching the exact rendered size), then from WordPress's
196 + * `-WIDTHxHEIGHT` resized-filename convention. Returns null when unknown.
197 + */
198 + private function resolve_dimensions( $tag ) {
199 + $src = $this->get_attr( $tag, 'src' );
200 +
201 + $id = $this->resolve_attachment_id( $tag );
202 + if ( $id ) {
203 + $dim = $this->dimensions_from_attachment( $id, $src );
204 + if ( $dim ) {
205 + return $dim;
206 + }
207 + }
208 +
209 + if ( $src && preg_match( '/-(\d+)x(\d+)\.(?:jpe?g|png|gif|webp|avif|bmp)(?:\?.*)?$/i', $src, $m ) ) {
210 + return [ (int) $m[1], (int) $m[2] ];
211 + }
212 +
213 + return null;
214 + }
215 +
216 + /**
217 + * Resolve the attachment id for an <img>: prefer the WordPress `wp-image-{id}`
218 + * class, otherwise map the src URL back to an attachment (aBlocks image blocks
219 + * don't emit the class). URL lookups are cached per request — and only run
220 + * when the class is absent — so the DB is hit at most once per unique URL.
221 + */
222 + private $id_cache = [];
223 + private $block_image_id = 0;
224 + private function resolve_attachment_id( $tag ) {
225 + if ( preg_match( '/wp-image-(\d+)/', $tag, $m ) ) {
226 + return (int) $m[1];
227 + }
228 + // The current block's stored attachment id (authoritative for aBlocks
229 + // image blocks, which don't emit the class).
230 + if ( $this->block_image_id ) {
231 + return $this->block_image_id;
232 + }
233 + $src = $this->get_attr( $tag, 'src' );
234 + if ( ! $src ) {
235 + return 0;
236 + }
237 + if ( ! array_key_exists( $src, $this->id_cache ) ) {
238 + $this->id_cache[ $src ] = (int) attachment_url_to_postid( $src );
239 + }
240 + return $this->id_cache[ $src ];
241 + }
242 +
243 + /**
244 + * Pull width/height from an attachment's stored metadata, preferring the
245 + * registered sub-size whose file matches the rendered src, falling back to
246 + * the full-size dimensions.
247 + */
248 + private function dimensions_from_attachment( $id, $src ) {
249 + if ( ! $id ) {
250 + return null;
251 + }
252 + $meta = wp_get_attachment_metadata( $id );
253 + if ( ! is_array( $meta ) || empty( $meta['width'] ) || empty( $meta['height'] ) ) {
254 + return null;
255 + }
256 + if ( $src && ! empty( $meta['sizes'] ) && is_array( $meta['sizes'] ) ) {
257 + $base = wp_basename( strtok( $src, '?' ) );
258 + foreach ( $meta['sizes'] as $size ) {
259 + if ( isset( $size['file'], $size['width'], $size['height'] ) && $size['file'] === $base ) {
260 + return [ (int) $size['width'], (int) $size['height'] ];
261 + }
262 + }
263 + }
264 + return [ (int) $meta['width'], (int) $meta['height'] ];
265 + }
266 +
267 + private function get_attr( $tag, $name ) {
268 + if ( preg_match( '/\b' . preg_quote( $name, '/' ) . '=(["\'])(.*?)\1/i', $tag, $m ) ) {
269 + return $m[2];
270 + }
271 + return '';
74 272 }
75 273 }