PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.4.0
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.4.0
1.4.1 1.4.0 1.3.7 1.3.6 1.3.5 1.3.4 1.3.3 1.3.2 1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 All 35 releases
xspeed / includes / modules / TurboRender / TurboRenderModule.php

TurboRenderModule.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN 1.4.0, at includes/modules/TurboRender/TurboRenderModule.php

679 lines 26.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Turbo Render — stamp below-fold page sections with
4 * `content-visibility: auto` so the browser skips their style & layout
5 * work until scroll approaches.
6 *
7 * Why this exists: on large-DOM builder pages the dominant share of TBT
8 * is Style & Layout, not script execution — measured live on a
9 * 2,003-element Elementor homepage: 661ms Style & Layout, biggest long
10 * task 543ms attributed to the document itself. JS delay/defer cannot
11 * touch that cost; `content-visibility` is the only browser primitive
12 * that skips rendering work for off-screen subtrees. Measured effect on
13 * that page: TBT 635ms -> 22-63ms across five runs.
14 *
15 * How: a buffer pass over the final HTML finds top-level section
16 * containers by class (Elementor, Divi, Bricks, Oxygen, Beaver Builder),
17 * and when no class matches falls back to the direct children of <main>
18 * — so any theme or builder gets the treatment. It leaves the first N
19 * alone (the above-fold estimate) and stamps the rest with a
20 * `data-xspeed-turbo` attribute. One
21 * inline <style> gives every stamped section
22 * `content-visibility: auto` + `contain-intrinsic-size: auto <est>` —
23 * the `auto` keyword remembers the real rendered size after first
24 * paint, so the estimate only matters before a section has ever been
25 * rendered, and a print stylesheet forces everything visible.
26 *
27 * Tier: Free (FEATURES.md Core Web Vitals #4 — the heuristic tier; the
28 * fold-beacon-measured variant is the Pro half of that row).
29 *
30 * @package XSpeed
31 */
32
33 declare(strict_types=1);
34
35 namespace XSpeed\Modules\TurboRender;
36
37 defined( 'ABSPATH' ) || exit;
38
39 use XSpeed\Module;
40
41 final class TurboRenderModule extends Module {
42
43 public const SLUG = 'turbo-render';
44 public const TIER = self::TIER_FREE;
45 public const VERSION = '1.0.0';
46
47 /**
48 * Top-level section containers, by class, across the major builders.
49 * Deliberately the TOP-LEVEL spellings only — e.g. Elementor's
50 * `elementor-top-section` / `e-parent`, never `elementor-section` /
51 * `e-con`, which also match nested wrappers and would stamp inside
52 * the sections we skip as above-fold. WPBakery is absent for the same
53 * reason: its inner rows carry `vc_row` too, so there is no nesting-safe
54 * spelling (users can still add it per site).
55 */
56 private const DEFAULT_CLASSES = array(
57 'elementor-top-section', // Elementor legacy sections.
58 'e-parent', // Elementor flexbox containers.
59 'et_pb_section', // Divi.
60 'brxe-section', // Bricks.
61 'ct-section', // Oxygen.
62 'fl-row', // Beaver Builder.
63 );
64
65 /** Sections presumed above the fold and left untouched. */
66 private const DEFAULT_SKIP_FIRST = 2;
67
68 /**
69 * Pre-first-render size estimate (px) for contain-intrinsic-size.
70 * Only the scrollbar sees it, and only until a section has rendered
71 * once — `contain-intrinsic-size: auto` then remembers the real size.
72 */
73 private const DEFAULT_INTRINSIC_PX = 800;
74
75 /**
76 * Spans whose markup is text, not the page: a section tag inside a
77 * JS template string or a comment must not be stamped.
78 */
79 /**
80 * Background images inside a deferred section stay off until the
81 * section is near the viewport. Scoped to `.xs-tbg`, which only the
82 * script below sets: without JavaScript, or without
83 * IntersectionObserver, nothing is held. Screen only, so a printed page
84 * keeps every background.
85 */
86 private const HOLD_STYLE = '<style id="xspeed-turbo-hold">@media screen{.xs-tbg [data-xspeed-turbo]:not([data-xspeed-near]),.xs-tbg [data-xspeed-turbo]:not([data-xspeed-near]) *{background-image:none!important}}</style>';
87
88 /**
89 * Releases a section's backgrounds 1000px before it scrolls into view.
90 * data-xs-nodelay keeps Delay JS from holding it until the first
91 * interaction, which would leave every deferred background blank.
92 */
93 private const HOLD_SCRIPT = '<script id="xspeed-turbo-hold-js" data-xs-nodelay>(function(d){if(!("IntersectionObserver" in window))return;d.documentElement.classList.add("xs-tbg");var io=new IntersectionObserver(function(es){es.forEach(function(e){if(e.isIntersecting){e.target.setAttribute("data-xspeed-near","");io.unobserve(e.target);}});},{rootMargin:"1000px 0px"});function go(){d.querySelectorAll("[data-xspeed-turbo]").forEach(function(s){io.observe(s);});}if(d.readyState!=="loading")go();else d.addEventListener("DOMContentLoaded",go);})(document);</script>';
94
95 private const MASKED_SPANS = '<script\b[^>]*>.*?</script>|<textarea\b[^>]*>.*?</textarea>|<noscript\b[^>]*>.*?</noscript>|<!--.*?-->';
96
97 /** Fallback: a child spanning less bytes than this is decoration (an empty notices div, a spacer), not a section. */
98 private const FALLBACK_MIN_SPAN = 150;
99
100 /** Fallback: a child holding this share of its siblings' combined bytes is a wrapper to unwrap, not a section. */
101 private const FALLBACK_DOMINANT = 0.6;
102
103 public function ui_metadata(): array {
104 return array(
105 'label' => __( 'Turbo Render', 'xspeed' ),
106 'icon' => 'Layers',
107 'description' => __( 'Shows the top of each page first and the rest as visitors scroll.', 'xspeed' ),
108 'group' => 'performance',
109 );
110 }
111
112 public function settings_schema(): array {
113 return array(
114 'enabled' => array(
115 'type' => 'bool',
116 'default' => false,
117 'label' => __( 'Render as visitors scroll', 'xspeed' ),
118 'description' => __( 'The browser draws what visitors see first and draws lower sections just before they scroll to them. Your content does not change.', 'xspeed' ),
119 ),
120 'skip_first' => array(
121 'type' => 'int',
122 'default' => self::DEFAULT_SKIP_FIRST,
123 'min' => 1,
124 'max' => 10,
125 'label' => __( 'Sections to render immediately', 'xspeed' ),
126 'description' => __( 'How many sections at the top are drawn right away. The default of 2 suits most pages; raise it if a section near the top appears late.', 'xspeed' ),
127 'dependsOn' => array( 'field' => 'enabled' ),
128 ),
129 'section_classes' => array(
130 'type' => 'list',
131 'default' => self::DEFAULT_CLASSES,
132 'label' => __( 'Section classes', 'xspeed' ),
133 'description' => __( 'CSS classes that mark a section. The defaults cover the main page builders, and other themes are detected on their own.', 'xspeed' ),
134 'advanced' => true,
135 'dependsOn' => array( 'field' => 'enabled' ),
136 ),
137 'excluded_classes' => array(
138 'type' => 'list',
139 'default' => array(),
140 'label' => __( 'Excluded classes', 'xspeed' ),
141 'description' => __( 'Sections with any of these classes are always drawn right away. Add one here if part of a section gets cut off where it overlaps the next.', 'xspeed' ),
142 'dependsOn' => array( 'field' => 'enabled' ),
143 ),
144 );
145 }
146
147 public function boot(): void {
148 if ( is_admin() || wp_doing_ajax() || wp_doing_cron() ) {
149 return;
150 }
151 // Deferred to `init`: reading the enabled flag builds
152 // settings_schema(), whose labels go through __(), and boot() runs
153 // on plugins_loaded — before WP 6.7 considers translation loading
154 // safe (same reasoning as BloatModule::boot()).
155 add_action( 'init', array( $this, 'boot_on_init' ) );
156 }
157
158 /** The real boot body — see boot() for why it runs on `init`. */
159 public function boot_on_init(): void {
160 if ( ! $this->get_setting( 'enabled', false ) ) {
161 return;
162 }
163 // After the CSS passes (combiner @5, a Pro CSS pass @6): they rewrite
164 // <head>, this pass rewrites body sections — ordering only matters in
165 // that our injected <style> must survive, and later passes never
166 // strip inline styles.
167 add_filter( 'xspeed_cache_final_html', array( $this, 'process' ), 8 );
168 }
169
170 /**
171 * Stamp below-fold top-level sections and inject the one style block.
172 *
173 * @param mixed $html Final page buffer.
174 * @return mixed
175 */
176 public function process( $html ) {
177 if ( ! is_string( $html ) || '' === $html ) {
178 return $html;
179 }
180 if ( false === stripos( $html, '</head>' ) ) {
181 return $html; // Not a full document (fragment, feed, JSON).
182 }
183 if ( function_exists( 'is_user_logged_in' ) && is_user_logged_in() ) {
184 return $html;
185 }
186 // A measurement fetch (`?xspeed_css=off` — the render a CSS
187 // generator reads) must see every section RENDERED. Shipped without
188 // this guard, a renderer measured a page whose below-fold sections
189 // the browser had skipped, judged their CSS unused, and pruned it —
190 // mobile CLS went 0.00 → 0.37 because the fold's own sizing rules
191 // were gone. The param is a shared contract (a Pro CSS module
192 // defines it), matched here by name because Free never references
193 // Pro code.
194 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only bypass detection; changes nothing.
195 if ( isset( $_GET['xspeed_css'] ) && 'off' === sanitize_text_field( wp_unslash( $_GET['xspeed_css'] ) ) ) {
196 return $html;
197 }
198
199 $classes = $this->section_classes();
200
201 /**
202 * Filter how many top-level sections stay untouched as above-fold.
203 *
204 * @param int $skip_first
205 */
206 $skip = max( 1, (int) apply_filters( 'xspeed_turbo_render_after', (int) $this->get_setting( 'skip_first', self::DEFAULT_SKIP_FIRST ) ) );
207
208 $masked = self::mask( $html );
209 $offsets = array();
210 $pattern = '#<(?:section|div|footer|main|article)\b[^>]*\bclass\s*=\s*(["\'])[^"\']*(?:' . implode( '|', array_map( 'preg_quote', $classes ) ) . ')[^"\']*\1[^>]*>#i';
211 if ( ! empty( $classes ) && preg_match_all( $pattern, $masked, $m, PREG_OFFSET_CAPTURE ) ) {
212 foreach ( $m[0] as $hit ) {
213 $offsets[] = (int) $hit[1];
214 }
215 }
216
217 /**
218 * Filter whether the structural fallback runs when no known section
219 * class matches: the direct children of <main> are treated as the
220 * page's sections, which is what makes Turbo Render work on block
221 * themes, classic themes, and builders not in the class list.
222 *
223 * @param bool $enabled
224 */
225 if ( empty( $offsets ) && apply_filters( 'xspeed_turbo_render_fallback', true ) ) {
226 $offsets = self::main_children( $masked, $skip );
227 }
228
229 $excluded = $this->excluded_pattern();
230
231 $seen = 0;
232 $edits = array();
233 foreach ( $offsets as $offset ) {
234 ++$seen;
235 if ( $seen <= $skip ) {
236 continue;
237 }
238 $end = self::tag_end( $masked, $offset );
239 if ( null === $end ) {
240 continue; // Unterminated tag at EOF — never stamp it.
241 }
242 $open_tag = substr( $html, $offset, $end - $offset );
243 if ( false !== stripos( $open_tag, 'data-xspeed-turbo' ) ) {
244 continue;
245 }
246 if ( '' !== $excluded && preg_match( $excluded, $open_tag ) ) {
247 continue; // Opted out — an overlap design the stamp would clip.
248 }
249 // `content-visibility` implies paint containment: it clips
250 // content that overhangs the section, and skipped iframes can
251 // blank or reload when the section re-renders. An embed holder
252 // (map, video) is never worth deferring — skip it. (Found live:
253 // a Kadence maps container painted over the card overlapping it.)
254 $span_end = self::element_end( $masked, $offset );
255 if ( null !== $span_end && false !== stripos( substr( $masked, $offset, $span_end - $offset ), '<iframe' ) ) {
256 continue;
257 }
258 $edits[] = $offset;
259 }
260
261 if ( empty( $edits ) ) {
262 return $html;
263 }
264
265 // Highest offset first, so earlier offsets stay valid as we splice.
266 // The attribute goes right after the tag name — the one spot
267 // guaranteed not to sit inside another attribute's value.
268 rsort( $edits );
269 foreach ( $edits as $offset ) {
270 $gap = (int) strcspn( $html, " \t\r\n/>", $offset + 1 );
271 $html = substr_replace( $html, ' data-xspeed-turbo=""', $offset + 1 + $gap, 0 );
272 }
273
274 /**
275 * Filter the pre-first-render intrinsic size estimate, in pixels.
276 *
277 * @param int $px
278 */
279 $px = max( 100, (int) apply_filters( 'xspeed_turbo_render_intrinsic_px', self::DEFAULT_INTRINSIC_PX ) );
280 $style = '<style id="xspeed-turbo">[data-xspeed-turbo]{content-visibility:auto;contain-intrinsic-size:auto ' . $px . 'px}@media print{[data-xspeed-turbo]{content-visibility:visible}}</style>';
281
282 /**
283 * Filter whether a deferred section's images and CSS backgrounds wait
284 * until the visitor scrolls near it.
285 *
286 * content-visibility skips a section's rendering, not its downloads:
287 * the browser still fetched every background a stylesheet gave it,
288 * and eager images in it. On a live Kadence page a 117 KB row
289 * background 3,600px down loaded before the hero heading painted,
290 * and PageSpeed counts every byte that lands before LCP.
291 *
292 * @param bool $hold Default true.
293 */
294 if ( apply_filters( 'xspeed_turbo_render_hold_media', true ) ) {
295 $html = self::lazy_section_images( $html );
296 $style .= self::HOLD_STYLE . self::HOLD_SCRIPT;
297 }
298
299 $head_end = stripos( $html, '</head>' );
300 return substr_replace( $html, $style, (int) $head_end, 0 );
301 }
302
303 /** @return string[] */
304 private function section_classes(): array {
305 $classes = $this->get_setting( 'section_classes', self::DEFAULT_CLASSES );
306 $classes = is_array( $classes ) ? array_values( array_filter( array_map( 'strval', $classes ) ) ) : self::DEFAULT_CLASSES;
307
308 /**
309 * Filter the class names identifying a top-level page section.
310 *
311 * @param string[] $classes
312 */
313 return self::sanitize_classes( (array) apply_filters( 'xspeed_turbo_render_classes', $classes ) );
314 }
315
316 /** @return string[] */
317 private function excluded_classes(): array {
318 $classes = $this->get_setting( 'excluded_classes', array() );
319 $classes = is_array( $classes ) ? $classes : array();
320
321 /**
322 * Filter the class names whose sections are never stamped.
323 *
324 * @param string[] $classes
325 */
326 return self::sanitize_classes( (array) apply_filters( 'xspeed_turbo_render_excluded_classes', $classes ) );
327 }
328
329 /**
330 * The exclusion regex for an open tag, or '' when nothing is excluded.
331 * Token-bounded, unlike the section scan: an exclusion is a user-typed
332 * remedy, and "card" silently matching "cardigan-grid" would make it
333 * look like the setting does nothing.
334 */
335 private function excluded_pattern(): string {
336 $classes = $this->excluded_classes();
337 if ( empty( $classes ) ) {
338 return '';
339 }
340 return '#\bclass\s*=\s*(["\'])[^"\']*(?<![A-Za-z0-9_-])(?:'
341 . implode( '|', array_map( 'preg_quote', $classes ) )
342 . ')(?![A-Za-z0-9_-])[^"\']*\1#i';
343 }
344
345 /**
346 * Class names end up inside a regex alternation; anything that is not a
347 * plausible CSS class token is dropped rather than escaped into
348 * something surprising.
349 *
350 * @param string[] $classes
351 * @return string[]
352 */
353 private static function sanitize_classes( array $classes ): array {
354 return array_values(
355 array_filter(
356 array_map( 'strval', $classes ),
357 static fn( string $c ): bool => (bool) preg_match( '/^[A-Za-z0-9_-]+$/', $c )
358 )
359 );
360 }
361
362 /**
363 * Structural fallback: the byte offsets of <main>'s section children.
364 *
365 * Themes love wrapper chains — Kadence renders <main> > a hero
366 * <section> + an empty notices <div> + one wrapper <div> holding
367 * everything else four levels deep. Two rules recover the real
368 * sections: drop tiny children (decoration, not layout), and while the
369 * list is too short to stamp anything, unwrap — in place — a child
370 * that dominates its siblings by byte share. Only a <div>/<article>
371 * unwraps, and a dominant FIRST child only when it is the sole child:
372 * wrappers in the wild are divs, but so are many heroes, and opening a
373 * hero would stamp above-fold content. The cap only guards against
374 * pathological markup.
375 *
376 * @param string $masked Markup with script/textarea/noscript/comments nulled.
377 * @param int $skip The above-fold allowance in effect.
378 * @return int[]
379 */
380 private static function main_children( string $masked, int $skip ): array {
381 if ( ! preg_match( '#<main\b[^>]*>#i', $masked, $open, PREG_OFFSET_CAPTURE ) ) {
382 return array();
383 }
384 $children = self::element_children( $masked, (int) $open[0][1] + strlen( (string) $open[0][0] ) );
385
386 for ( $level = 0; $level < 10; $level++ ) {
387 $children = array_values(
388 array_filter( $children, static fn( array $c ): bool => ( $c['end'] - $c['start'] ) >= self::FALLBACK_MIN_SPAN )
389 );
390 if ( count( $children ) > $skip || empty( $children ) ) {
391 break;
392 }
393 $total = array_sum( array_map( static fn( array $c ): int => $c['end'] - $c['start'], $children ) );
394 $unwrapped = false;
395 foreach ( $children as $i => $child ) {
396 if ( ! in_array( $child['tag'], array( 'div', 'article' ), true ) ) {
397 continue;
398 }
399 if ( 0 === $i && count( $children ) > 1 ) {
400 continue; // A dominant first child among siblings is a hero, not a wrapper.
401 }
402 if ( ( $child['end'] - $child['start'] ) < self::FALLBACK_DOMINANT * $total ) {
403 continue;
404 }
405 $end = self::tag_end( $masked, $child['start'] );
406 if ( null === $end ) {
407 break 2;
408 }
409 $inner = self::element_children( $masked, $end );
410 if ( empty( $inner ) ) {
411 break 2;
412 }
413 array_splice( $children, $i, 1, $inner );
414 $unwrapped = true;
415 break;
416 }
417 if ( ! $unwrapped ) {
418 break;
419 }
420 }
421 return array_map( static fn( array $c ): int => $c['start'], $children );
422 }
423
424 /**
425 * Byte spans of an element's direct section-shaped children.
426 *
427 * A tag walk with an open-tag stack, not a depth counter, so the HTML5
428 * that classic themes actually emit doesn't desync it: implicitly
429 * closed tags (<li>a<li>b, an unclosed <p>) are popped by the matching
430 * ancestor close, a stray close of an implicit tag is ignored, and a
431 * trailing slash on a non-void tag is meaningless (HTML5) so <div/> is
432 * an OPEN div. A close tag for anything not on the stack ends the walk
433 * — normally the container's own close. Only section-shaped tags count
434 * as children: stamping a stray <p> or <h2> would put an 800px
435 * intrinsic-size estimate on a one-line element and wreck the
436 * scrollbar.
437 *
438 * @param string $masked Markup with script/textarea/noscript/comments nulled.
439 * @param int $cursor Byte offset just past the container's open tag.
440 * @return array<int, array{start:int, end:int, tag:string}>
441 */
442 private static function element_children( string $masked, int $cursor ): array {
443 static $void = array( 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr' );
444 static $want = array( 'div', 'section', 'article', 'footer', 'aside', 'figure', 'table', 'ul', 'ol' );
445 static $implicit = array( 'p', 'li', 'dt', 'dd', 'td', 'th', 'tr', 'option', 'optgroup' );
446
447 $children = array();
448 $pending = null;
449 $stack = array();
450 while ( preg_match( '#<(/?)([a-zA-Z][a-zA-Z0-9-]*)((?:"[^"]*"|\'[^\']*\'|[^>"\'])*)>#', $masked, $t, PREG_OFFSET_CAPTURE, $cursor ) ) {
451 $offset = (int) $t[0][1];
452 $cursor = $offset + strlen( (string) $t[0][0] );
453 $closing = '' !== $t[1][0];
454 $tag = strtolower( (string) $t[2][0] );
455 if ( in_array( $tag, $void, true ) ) {
456 continue;
457 }
458 if ( ! $closing ) {
459 if ( in_array( $tag, $implicit, true ) && end( $stack ) === $tag ) {
460 array_pop( $stack ); // A sibling <li>/<p>/<td> implicitly closes the previous one.
461 }
462 if ( empty( $stack ) && in_array( $tag, $want, true ) ) {
463 $pending = array(
464 'start' => $offset,
465 'end' => $offset,
466 'tag' => $tag,
467 );
468 }
469 $stack[] = $tag;
470 continue;
471 }
472 if ( ! in_array( $tag, $stack, true ) ) {
473 if ( in_array( $tag, $implicit, true ) ) {
474 continue; // Stray </p>-style close: harmless, skip it.
475 }
476 break; // The container's own close tag: the walk is done.
477 }
478 while ( ! empty( $stack ) && array_pop( $stack ) !== $tag ) {
479 continue; // Unclosed implicit tags between here and the match.
480 }
481 if ( empty( $stack ) && null !== $pending ) {
482 $pending['end'] = $cursor;
483 $children[] = $pending;
484 $pending = null;
485 }
486 }
487 return $children;
488 }
489
490 /**
491 * The offset just past an element's close tag — the same tag walk as
492 * element_children(), seeded with the element's own tag, so implicitly
493 * closed tags don't desync it. Null when the element never closes (or
494 * the markup is too broken to tell), in which case the caller keeps
495 * the old behavior rather than guessing at a span.
496 *
497 * @param string $masked Markup with script/textarea/noscript/comments nulled.
498 * @param int $offset Byte offset of the element's `<`.
499 * @return int|null
500 */
501 private static function element_end( string $masked, int $offset ): ?int {
502 static $void = array( 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr' );
503 static $implicit = array( 'p', 'li', 'dt', 'dd', 'td', 'th', 'tr', 'option', 'optgroup' );
504
505 if ( ! preg_match( '#\G<([a-zA-Z][a-zA-Z0-9-]*)#', $masked, $open, 0, $offset ) ) {
506 return null;
507 }
508 $cursor = self::tag_end( $masked, $offset );
509 if ( null === $cursor ) {
510 return null;
511 }
512 $stack = array( strtolower( (string) $open[1] ) );
513 while ( preg_match( '#<(/?)([a-zA-Z][a-zA-Z0-9-]*)((?:"[^"]*"|\'[^\']*\'|[^>"\'])*)>#', $masked, $t, PREG_OFFSET_CAPTURE, $cursor ) ) {
514 $cursor = (int) $t[0][1] + strlen( (string) $t[0][0] );
515 $closing = '' !== $t[1][0];
516 $tag = strtolower( (string) $t[2][0] );
517 if ( in_array( $tag, $void, true ) ) {
518 continue;
519 }
520 if ( ! $closing ) {
521 if ( in_array( $tag, $implicit, true ) && end( $stack ) === $tag ) {
522 array_pop( $stack );
523 }
524 $stack[] = $tag;
525 continue;
526 }
527 if ( ! in_array( $tag, $stack, true ) ) {
528 if ( in_array( $tag, $implicit, true ) ) {
529 continue; // Stray </p>-style close: harmless, skip it.
530 }
531 return null; // A close for an ancestor: the element never closed.
532 }
533 while ( ! empty( $stack ) && array_pop( $stack ) !== $tag ) {
534 continue;
535 }
536 if ( empty( $stack ) ) {
537 return $cursor;
538 }
539 }
540 return null;
541 }
542
543 /**
544 * The offset just past an open tag's `>`, quote-aware — a raw strpos
545 * would stop at a `>` inside an attribute value.
546 *
547 * @param string $masked Markup with script/textarea/noscript/comments nulled.
548 * @param int $offset Byte offset of the tag's `<`.
549 * @return int|null Null when the tag never terminates.
550 */
551 private static function tag_end( string $masked, int $offset ): ?int {
552 if ( ! preg_match( '#\G<(/?)[a-zA-Z][a-zA-Z0-9-]*(?:"[^"]*"|\'[^\']*\'|[^>"\'])*>#', $masked, $m, 0, $offset ) ) {
553 return null;
554 }
555 return $offset + strlen( $m[0] );
556 }
557
558 /**
559 * Make the images inside stamped sections lazy.
560 *
561 * A stamped section is below the fold by Turbo Render's own count, so
562 * loading="eager" there buys nothing: the Lazy module's eager slot had
563 * gone to the first image of a slider 5,600px down. Left alone: an
564 * image marked fetchpriority="high", data-skip-lazy or data-no-lazy,
565 * one matching Lazy's Excluded Images, and any loading value other
566 * than eager.
567 */
568 private static function lazy_section_images( string $html ): string {
569 $masked = self::mask( $html );
570 if ( ! preg_match_all( '#<[a-zA-Z][a-zA-Z0-9-]*\s+data-xspeed-turbo=""#', $masked, $opens, PREG_OFFSET_CAPTURE ) ) {
571 return $html;
572 }
573 $spans = array();
574 $until = -1;
575 foreach ( $opens[0] as $open ) {
576 $start = (int) $open[1];
577 if ( $start < $until ) {
578 continue; // Nested inside a span already taken.
579 }
580 $end = self::element_end( $masked, $start );
581 if ( null === $end ) {
582 continue;
583 }
584 $spans[] = array( $start, $end );
585 $until = $end;
586 }
587 if ( empty( $spans ) || ! preg_match_all( '#<img\b(?:"[^"]*"|\'[^\']*\'|[^>"\'])*>#i', $masked, $imgs, PREG_OFFSET_CAPTURE ) ) {
588 return $html;
589 }
590
591 $lazy = \XSpeed\Settings_Manager::get( 'lazy' );
592 $excluded = is_array( $lazy ) && is_array( $lazy['excluded_images'] ?? null ) ? $lazy['excluded_images'] : array();
593
594 $edits = array();
595 foreach ( $imgs[0] as $img ) {
596 $at = (int) $img[1];
597 $in = false;
598 foreach ( $spans as $span ) {
599 if ( $at > $span[0] && $at < $span[1] ) {
600 $in = true;
601 break;
602 }
603 }
604 if ( ! $in ) {
605 continue;
606 }
607 $tag = substr( $html, $at, strlen( (string) $img[0] ) );
608 $new = self::lazy_img( $tag, $excluded );
609 if ( $new !== $tag ) {
610 $edits[] = array( $at, strlen( $tag ), $new );
611 }
612 }
613 foreach ( array_reverse( $edits ) as $edit ) {
614 $html = substr_replace( $html, $edit[2], $edit[0], $edit[1] );
615 }
616 return $html;
617 }
618
619 /**
620 * @param string $tag One <img> tag.
621 * @param string[] $excluded Lazy's Excluded Images patterns.
622 */
623 private static function lazy_img( string $tag, array $excluded ): string {
624 if ( false !== stripos( $tag, 'data-skip-lazy' ) || false !== stripos( $tag, 'data-no-lazy' )
625 || \XSpeed\Lazy_Loader::has_high_fetchpriority( $tag ) ) {
626 return $tag;
627 }
628 foreach ( $excluded as $pattern ) {
629 $pattern = (string) $pattern;
630 if ( '' !== $pattern && false !== stripos( $tag, $pattern ) ) {
631 return $tag;
632 }
633 }
634 if ( preg_match( '#(?<![-\w])loading\s*=\s*(["\']?)([^"\'\s>]*)\1#i', $tag, $m, PREG_OFFSET_CAPTURE ) ) {
635 if ( 'eager' !== strtolower( (string) $m[2][0] ) ) {
636 return $tag;
637 }
638 return substr_replace( $tag, 'loading="lazy"', (int) $m[0][1], strlen( (string) $m[0][0] ) );
639 }
640 return (string) preg_replace( '#^<img\b#i', '<img loading="lazy"', $tag, 1 );
641 }
642
643 private static function mask( string $html ): string {
644 return (string) preg_replace_callback(
645 '#' . self::MASKED_SPANS . '#is',
646 static fn( array $m ): string => str_repeat( "\0", strlen( $m[0] ) ),
647 $html
648 );
649 }
650
651 public function cli_commands(): array {
652 return array(
653 array(
654 'name' => 'xspeed turbo-render status',
655 'callback' => array( $this, 'cli_status' ),
656 'shortdesc' => 'Show Turbo Render status.',
657 'ai_hint' => 'Is Turbo Render (content-visibility stamping of below-fold sections) on, and how many sections render immediately? Use when diagnosing TBT / main-thread style & layout cost on long builder pages.',
658 'synopsis' => array(),
659 ),
660 );
661 }
662
663 /**
664 * `wp xspeed turbo-render status`.
665 *
666 * @param array $args Positional args (unused).
667 * @param array $assoc Associative args (unused).
668 */
669 public function cli_status( array $args, array $assoc ): void {
670 unset( $args, $assoc );
671 $enabled = (bool) $this->get_setting( 'enabled', false );
672 \WP_CLI::log( 'Turbo Render: ' . ( $enabled ? 'enabled' : 'disabled' ) );
673 \WP_CLI::log( 'Immediate sections: first ' . (int) $this->get_setting( 'skip_first', self::DEFAULT_SKIP_FIRST ) . ' sections' );
674 \WP_CLI::log( 'Section classes: ' . implode( ', ', $this->section_classes() ) );
675 $excluded = $this->excluded_classes();
676 \WP_CLI::log( 'Excluded classes: ' . ( empty( $excluded ) ? '(none)' : implode( ', ', $excluded ) ) );
677 }
678 }
679