PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.4.2
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.4.2
1.4.2 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 All 36 releases
xspeed / includes / modules / TurboRender / TurboRenderModule.php

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

826 lines 32.3 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 /**
66 * Sections presumed above the fold and left untouched. Three, not two:
67 * the only other automatic implementation (WP Rocket's lazy render)
68 * refuses anything within 1800px of the viewport bottom, and until the
69 * fold is measured a section count should err the same way. Only a
70 * fresh install sees the change — a stored value is kept as is.
71 */
72 private const DEFAULT_SKIP_FIRST = 3;
73
74 /**
75 * Pre-first-render size estimate (px) for contain-intrinsic-size.
76 * Only the scrollbar sees it, and only until a section has rendered
77 * once — `contain-intrinsic-size: auto` then remembers the real size.
78 */
79 private const DEFAULT_INTRINSIC_PX = 800;
80
81 /**
82 * Spans whose markup is text, not the page: a section tag inside a
83 * JS template string or a comment must not be stamped.
84 */
85 /**
86 * Background images inside a deferred section stay off until the
87 * section is near the viewport. Scoped to `.xs-tbg`, which only the
88 * script below sets: without JavaScript, or without
89 * IntersectionObserver, nothing is held. Screen only, so a printed page
90 * keeps every background.
91 */
92 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>';
93
94 /**
95 * Releases a section's backgrounds 1000px before it scrolls into view.
96 * data-xs-nodelay keeps Delay JS from holding it until the first
97 * interaction, which would leave every deferred background blank.
98 */
99 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>';
100
101 private const MASKED_SPANS = '<script\b[^>]*>.*?</script>|<textarea\b[^>]*>.*?</textarea>|<noscript\b[^>]*>.*?</noscript>|<!--.*?-->';
102
103 /** Fallback: a child spanning less bytes than this is decoration (an empty notices div, a spacer), not a section. */
104 private const FALLBACK_MIN_SPAN = 150;
105
106 /** Fallback: a child holding this share of its siblings' combined bytes is a wrapper to unwrap, not a section. */
107 private const FALLBACK_DOMINANT = 0.6;
108
109 /** Our own output-buffer nesting level, when we had to open one. */
110 private ?int $buffer_level = null;
111
112 public function ui_metadata(): array {
113 return array(
114 'label' => __( 'Turbo Render', 'xspeed' ),
115 'icon' => 'Layers',
116 'description' => __( 'Shows the top of each page first and the rest as visitors scroll.', 'xspeed' ),
117 'group' => 'performance',
118 );
119 }
120
121 public function settings_schema(): array {
122 return array(
123 'enabled' => array(
124 'type' => 'bool',
125 'default' => false,
126 'label' => __( 'Render as visitors scroll', 'xspeed' ),
127 'description' => __( 'The browser draws what visitors see first and draws lower sections just before they scroll to them. Your content does not change.', 'xspeed' ),
128 ),
129 'skip_first' => array(
130 'type' => 'int',
131 'default' => self::DEFAULT_SKIP_FIRST,
132 'min' => 1,
133 'max' => 10,
134 'label' => __( 'Sections to render immediately', 'xspeed' ),
135 'description' => __( 'How many sections at the top are drawn right away. The default of 3 suits most pages; raise it if a section near the top appears late.', 'xspeed' ),
136 'dependsOn' => array( 'field' => 'enabled' ),
137 ),
138 'section_classes' => array(
139 'type' => 'list',
140 'default' => self::DEFAULT_CLASSES,
141 'label' => __( 'Section classes', 'xspeed' ),
142 'description' => __( 'CSS classes that mark a section. The defaults cover the main page builders, and other themes are detected on their own.', 'xspeed' ),
143 'advanced' => true,
144 'dependsOn' => array( 'field' => 'enabled' ),
145 ),
146 'excluded_classes' => array(
147 'type' => 'list',
148 'default' => array(),
149 'label' => __( 'Excluded classes', 'xspeed' ),
150 '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' ),
151 'dependsOn' => array( 'field' => 'enabled' ),
152 ),
153 );
154 }
155
156 public function boot(): void {
157 if ( is_admin() || wp_doing_ajax() || wp_doing_cron() ) {
158 return;
159 }
160 // Deferred to `init`: reading the enabled flag builds
161 // settings_schema(), whose labels go through __(), and boot() runs
162 // on plugins_loaded — before WP 6.7 considers translation loading
163 // safe (same reasoning as BloatModule::boot()).
164 add_action( 'init', array( $this, 'boot_on_init' ) );
165 }
166
167 /** The real boot body — see boot() for why it runs on `init`. */
168 public function boot_on_init(): void {
169 if ( ! $this->get_setting( 'enabled', false ) ) {
170 return;
171 }
172 // After the CSS passes (combiner @5, a Pro CSS pass @6): they rewrite
173 // <head>, this pass rewrites body sections — ordering only matters in
174 // that our injected <style> must survive, and later passes never
175 // strip inline styles.
176 add_filter( 'xspeed_cache_final_html', array( $this, 'process' ), 8 );
177 // That filter fires only when the page cache stores a page. With the
178 // cache off, or on a URL it excludes, nothing else hands us the HTML,
179 // so open our own buffer then (#507), the way Combine CSS does (#195).
180 // Combine's buffer opens first and is the outer one, so this pass
181 // runs before it there: harmless, since we only add a <style> to
182 // <head>, which Combine leaves in place.
183 add_action( 'template_redirect', array( $this, 'maybe_buffer' ), 1 );
184 }
185
186 /**
187 * Buffer the page ourselves when the page cache will not.
188 *
189 * Stands down on the same requests Combine CSS's buffer does, and when
190 * the page cache is buffering: it will call process() through its filter,
191 * and a second buffer would only copy the page again.
192 */
193 public function maybe_buffer(): void {
194 if ( is_admin() || wp_doing_ajax() || wp_doing_cron()
195 || ( defined( 'REST_REQUEST' ) && REST_REQUEST )
196 || ( defined( 'WP_CLI' ) && WP_CLI )
197 || ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST )
198 || ( function_exists( 'is_feed' ) && is_feed() )
199 || ( function_exists( 'is_user_logged_in' ) && is_user_logged_in() )
200 || \XSpeed\Builder_Editor::is_active() ) {
201 return; // process() never stamps for a signed-in visitor either.
202 }
203 if ( class_exists( '\\XSpeed\\Cache' ) && \XSpeed\Cache::is_buffering() ) {
204 return;
205 }
206 if ( $this->request_opted_out() ) {
207 return;
208 }
209 ob_start( array( $this, 'filter_buffer' ) );
210 $this->buffer_level = ob_get_level();
211 add_action( 'shutdown', array( $this, 'close_buffer' ), 0 );
212 }
213
214 /**
215 * Pages the site has marked as not-for-optimizing, which Turbo Render
216 * leaves alone even with the page cache off.
217 *
218 * With the cache on, a page reached Turbo Render only by being cached, so
219 * the cache's URL exclusions (cart, checkout, my-account by default, plus
220 * whatever the site added) kept it off transactional pages. Its own
221 * buffer must keep that promise, or turning on Turbo Render would start
222 * deferring sections of a checkout. The WooCommerce conditionals cover a
223 * store whose cart or checkout lives at a translated slug the URL list
224 * does not name.
225 */
226 public function request_opted_out(): bool {
227 foreach ( array( 'is_cart', 'is_checkout', 'is_account_page' ) as $fn ) {
228 if ( function_exists( $fn ) && $fn() ) {
229 return true;
230 }
231 }
232 if ( ! class_exists( '\\XSpeed\\Settings_Manager' ) || ! class_exists( '\\XSpeed\\Cache' ) ) {
233 return false;
234 }
235 $cache = \XSpeed\Settings_Manager::get( 'cache' );
236 $excluded = is_array( $cache['excluded_urls'] ?? null ) ? $cache['excluded_urls'] : array();
237 // The cache's own matcher: it keeps percent-escapes that
238 // sanitize_text_field() would delete, so an entry for a non-ASCII
239 // slug matches here too.
240 return array() !== $excluded && \XSpeed\Cache::request_matches_exclusions( $excluded );
241 }
242
243 /** ob_start() callback. */
244 public function filter_buffer( string $buffer ): string {
245 $out = $this->process( $buffer );
246 return is_string( $out ) ? $out : $buffer;
247 }
248
249 /** Flush only the buffer we opened. */
250 public function close_buffer(): void {
251 if ( null !== $this->buffer_level && ob_get_level() >= $this->buffer_level ) {
252 ob_end_flush();
253 $this->buffer_level = null;
254 }
255 }
256
257 /**
258 * Stamp below-fold top-level sections and inject the one style block.
259 *
260 * @param mixed $html Final page buffer.
261 * @return mixed
262 */
263 public function process( $html ) {
264 if ( ! is_string( $html ) || '' === $html ) {
265 return $html;
266 }
267 if ( false === stripos( $html, '</head>' ) ) {
268 return $html; // Not a full document (fragment, feed, JSON).
269 }
270 // Already processed: our buffer and the cache filter can both see a
271 // page, and stamping twice would add a second style block.
272 if ( false !== strpos( $html, '<style id="xspeed-turbo">' ) ) {
273 return $html;
274 }
275 if ( function_exists( 'is_user_logged_in' ) && is_user_logged_in() ) {
276 return $html;
277 }
278 // A measurement fetch (`?xspeed_css=off` — the render a CSS
279 // generator reads) must see every section RENDERED. Shipped without
280 // this guard, a renderer measured a page whose below-fold sections
281 // the browser had skipped, judged their CSS unused, and pruned it —
282 // mobile CLS went 0.00 → 0.37 because the fold's own sizing rules
283 // were gone. The param is a shared contract (a Pro CSS module
284 // defines it), matched here by name because Free never references
285 // Pro code.
286 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only bypass detection; changes nothing.
287 if ( isset( $_GET['xspeed_css'] ) && 'off' === sanitize_text_field( wp_unslash( $_GET['xspeed_css'] ) ) ) {
288 return $html;
289 }
290
291 $classes = $this->section_classes();
292
293 /**
294 * Filter how many top-level sections stay untouched as above-fold.
295 *
296 * @param int $skip_first
297 */
298 $skip = max( 1, (int) apply_filters( 'xspeed_turbo_render_after', (int) $this->get_setting( 'skip_first', self::DEFAULT_SKIP_FIRST ) ) );
299
300 $masked = self::mask( $html );
301 $offsets = array();
302 $pattern = '#<(?:section|div|footer|main|article)\b[^>]*\bclass\s*=\s*(["\'])[^"\']*(?:' . implode( '|', array_map( 'preg_quote', $classes ) ) . ')[^"\']*\1[^>]*>#i';
303 if ( ! empty( $classes ) && preg_match_all( $pattern, $masked, $m, PREG_OFFSET_CAPTURE ) ) {
304 foreach ( $m[0] as $hit ) {
305 $offsets[] = (int) $hit[1];
306 }
307 }
308
309 /**
310 * Filter whether the structural fallback runs when no known section
311 * class matches: the direct children of <main> are treated as the
312 * page's sections, which is what makes Turbo Render work on block
313 * themes, classic themes, and builders not in the class list.
314 *
315 * @param bool $enabled
316 */
317 if ( empty( $offsets ) && apply_filters( 'xspeed_turbo_render_fallback', true ) ) {
318 $offsets = self::main_children( $masked, $skip );
319 }
320
321 $excluded = $this->excluded_pattern();
322
323 // Document order and the end of <body>, the fallback span bound for
324 // the containment check below.
325 $sorted = $offsets;
326 sort( $sorted );
327 $body_end = stripos( $masked, '</body>' );
328 $doc_end = false === $body_end ? strlen( $masked ) : (int) $body_end;
329
330 $seen = 0;
331 $edits = array();
332 foreach ( $offsets as $offset ) {
333 ++$seen;
334 if ( $seen <= $skip ) {
335 continue;
336 }
337 $end = self::tag_end( $masked, $offset );
338 if ( null === $end ) {
339 continue; // Unterminated tag at EOF — never stamp it.
340 }
341 $open_tag = substr( $html, $offset, $end - $offset );
342 if ( false !== stripos( $open_tag, 'data-xspeed-turbo' ) ) {
343 continue;
344 }
345 if ( '' !== $excluded && preg_match( $excluded, $open_tag ) ) {
346 continue; // Opted out — an overlap design the stamp would clip.
347 }
348 // `content-visibility` implies paint containment: it clips
349 // content that overhangs the section, and skipped iframes can
350 // blank or reload when the section re-renders. An embed holder
351 // (map, video) is never worth deferring — skip it. (Found live:
352 // a Kadence maps container painted over the card overlapping it.)
353 $span_end = self::element_end( $masked, $offset );
354 if ( null !== $span_end && false !== stripos( substr( $masked, $offset, $span_end - $offset ), '<iframe' ) ) {
355 continue;
356 }
357 if ( self::breaks_under_containment( $masked, $offset, $span_end, $sorted, $doc_end ) ) {
358 continue;
359 }
360 $edits[] = $offset;
361 }
362
363 if ( empty( $edits ) ) {
364 return $html;
365 }
366
367 // Highest offset first, so earlier offsets stay valid as we splice.
368 // The attribute goes right after the tag name — the one spot
369 // guaranteed not to sit inside another attribute's value.
370 rsort( $edits );
371 foreach ( $edits as $offset ) {
372 $gap = (int) strcspn( $html, " \t\r\n/>", $offset + 1 );
373 $html = substr_replace( $html, ' data-xspeed-turbo=""', $offset + 1 + $gap, 0 );
374 }
375
376 /**
377 * Filter the pre-first-render intrinsic size estimate, in pixels.
378 *
379 * @param int $px
380 */
381 $px = max( 100, (int) apply_filters( 'xspeed_turbo_render_intrinsic_px', self::DEFAULT_INTRINSIC_PX ) );
382 $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>';
383
384 /**
385 * Filter whether a deferred section's images and CSS backgrounds wait
386 * until the visitor scrolls near it.
387 *
388 * content-visibility skips a section's rendering, not its downloads:
389 * the browser still fetched every background a stylesheet gave it,
390 * and eager images in it. On a live Kadence page a 117 KB row
391 * background 3,600px down loaded before the hero heading painted,
392 * and PageSpeed counts every byte that lands before LCP.
393 *
394 * @param bool $hold Default true.
395 */
396 if ( apply_filters( 'xspeed_turbo_render_hold_media', true ) ) {
397 $html = self::lazy_section_images( $html );
398 $style .= self::HOLD_STYLE . self::HOLD_SCRIPT;
399 }
400
401 $head_end = stripos( $html, '</head>' );
402 return substr_replace( $html, $style, (int) $head_end, 0 );
403 }
404
405 /** @return string[] */
406 private function section_classes(): array {
407 $classes = $this->get_setting( 'section_classes', self::DEFAULT_CLASSES );
408 $classes = is_array( $classes ) ? array_values( array_filter( array_map( 'strval', $classes ) ) ) : self::DEFAULT_CLASSES;
409
410 /**
411 * Filter the class names identifying a top-level page section.
412 *
413 * @param string[] $classes
414 */
415 return self::sanitize_classes( (array) apply_filters( 'xspeed_turbo_render_classes', $classes ) );
416 }
417
418 /** @return string[] */
419 private function excluded_classes(): array {
420 $classes = $this->get_setting( 'excluded_classes', array() );
421 $classes = is_array( $classes ) ? $classes : array();
422
423 /**
424 * Filter the class names whose sections are never stamped.
425 *
426 * @param string[] $classes
427 */
428 return self::sanitize_classes( (array) apply_filters( 'xspeed_turbo_render_excluded_classes', $classes ) );
429 }
430
431 /**
432 * The exclusion regex for an open tag, or '' when nothing is excluded.
433 * Token-bounded, unlike the section scan: an exclusion is a user-typed
434 * remedy, and "card" silently matching "cardigan-grid" would make it
435 * look like the setting does nothing.
436 */
437 private function excluded_pattern(): string {
438 $classes = $this->excluded_classes();
439 if ( empty( $classes ) ) {
440 return '';
441 }
442 return '#\bclass\s*=\s*(["\'])[^"\']*(?<![A-Za-z0-9_-])(?:'
443 . implode( '|', array_map( 'preg_quote', $classes ) )
444 . ')(?![A-Za-z0-9_-])[^"\']*\1#i';
445 }
446
447 /**
448 * Class names end up inside a regex alternation; anything that is not a
449 * plausible CSS class token is dropped rather than escaped into
450 * something surprising.
451 *
452 * @param string[] $classes
453 * @return string[]
454 */
455 private static function sanitize_classes( array $classes ): array {
456 return array_values(
457 array_filter(
458 array_map( 'strval', $classes ),
459 static fn( string $c ): bool => (bool) preg_match( '/^[A-Za-z0-9_-]+$/', $c )
460 )
461 );
462 }
463
464 /**
465 * Structural fallback: the byte offsets of <main>'s section children.
466 *
467 * Themes love wrapper chains — Kadence renders <main> > a hero
468 * <section> + an empty notices <div> + one wrapper <div> holding
469 * everything else four levels deep. Two rules recover the real
470 * sections: drop tiny children (decoration, not layout), and while the
471 * list is too short to stamp anything, unwrap — in place — a child
472 * that dominates its siblings by byte share. Only a <div>/<article>
473 * unwraps, and a dominant FIRST child only when it is the sole child:
474 * wrappers in the wild are divs, but so are many heroes, and opening a
475 * hero would stamp above-fold content. The cap only guards against
476 * pathological markup.
477 *
478 * @param string $masked Markup with script/textarea/noscript/comments nulled.
479 * @param int $skip The above-fold allowance in effect.
480 * @return int[]
481 */
482 private static function main_children( string $masked, int $skip ): array {
483 if ( ! preg_match( '#<main\b[^>]*>#i', $masked, $open, PREG_OFFSET_CAPTURE ) ) {
484 return array();
485 }
486 $children = self::element_children( $masked, (int) $open[0][1] + strlen( (string) $open[0][0] ) );
487
488 for ( $level = 0; $level < 10; $level++ ) {
489 $children = array_values(
490 array_filter( $children, static fn( array $c ): bool => ( $c['end'] - $c['start'] ) >= self::FALLBACK_MIN_SPAN )
491 );
492 if ( count( $children ) > $skip || empty( $children ) ) {
493 break;
494 }
495 $total = array_sum( array_map( static fn( array $c ): int => $c['end'] - $c['start'], $children ) );
496 $unwrapped = false;
497 foreach ( $children as $i => $child ) {
498 if ( ! in_array( $child['tag'], array( 'div', 'article' ), true ) ) {
499 continue;
500 }
501 if ( 0 === $i && count( $children ) > 1 ) {
502 continue; // A dominant first child among siblings is a hero, not a wrapper.
503 }
504 if ( ( $child['end'] - $child['start'] ) < self::FALLBACK_DOMINANT * $total ) {
505 continue;
506 }
507 $end = self::tag_end( $masked, $child['start'] );
508 if ( null === $end ) {
509 break 2;
510 }
511 $inner = self::element_children( $masked, $end );
512 if ( empty( $inner ) ) {
513 break 2;
514 }
515 array_splice( $children, $i, 1, $inner );
516 $unwrapped = true;
517 break;
518 }
519 if ( ! $unwrapped ) {
520 break;
521 }
522 }
523 return array_map( static fn( array $c ): int => $c['start'], $children );
524 }
525
526 /**
527 * Byte spans of an element's direct section-shaped children.
528 *
529 * A tag walk with an open-tag stack, not a depth counter, so the HTML5
530 * that classic themes actually emit doesn't desync it: implicitly
531 * closed tags (<li>a<li>b, an unclosed <p>) are popped by the matching
532 * ancestor close, a stray close of an implicit tag is ignored, and a
533 * trailing slash on a non-void tag is meaningless (HTML5) so <div/> is
534 * an OPEN div. A close tag for anything not on the stack ends the walk
535 * — normally the container's own close. Only section-shaped tags count
536 * as children: stamping a stray <p> or <h2> would put an 800px
537 * intrinsic-size estimate on a one-line element and wreck the
538 * scrollbar.
539 *
540 * @param string $masked Markup with script/textarea/noscript/comments nulled.
541 * @param int $cursor Byte offset just past the container's open tag.
542 * @return array<int, array{start:int, end:int, tag:string}>
543 */
544 private static function element_children( string $masked, int $cursor ): array {
545 static $void = array( 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr' );
546 static $want = array( 'div', 'section', 'article', 'footer', 'aside', 'figure', 'table', 'ul', 'ol' );
547 static $implicit = array( 'p', 'li', 'dt', 'dd', 'td', 'th', 'tr', 'option', 'optgroup' );
548
549 $children = array();
550 $pending = null;
551 $stack = array();
552 while ( preg_match( '#<(/?)([a-zA-Z][a-zA-Z0-9-]*)((?:"[^"]*"|\'[^\']*\'|[^>"\'])*)>#', $masked, $t, PREG_OFFSET_CAPTURE, $cursor ) ) {
553 $offset = (int) $t[0][1];
554 $cursor = $offset + strlen( (string) $t[0][0] );
555 $closing = '' !== $t[1][0];
556 $tag = strtolower( (string) $t[2][0] );
557 if ( in_array( $tag, $void, true ) ) {
558 continue;
559 }
560 if ( ! $closing ) {
561 if ( in_array( $tag, $implicit, true ) && end( $stack ) === $tag ) {
562 array_pop( $stack ); // A sibling <li>/<p>/<td> implicitly closes the previous one.
563 }
564 if ( empty( $stack ) && in_array( $tag, $want, true ) ) {
565 $pending = array(
566 'start' => $offset,
567 'end' => $offset,
568 'tag' => $tag,
569 );
570 }
571 $stack[] = $tag;
572 continue;
573 }
574 if ( ! in_array( $tag, $stack, true ) ) {
575 if ( in_array( $tag, $implicit, true ) ) {
576 continue; // Stray </p>-style close: harmless, skip it.
577 }
578 break; // The container's own close tag: the walk is done.
579 }
580 while ( ! empty( $stack ) && array_pop( $stack ) !== $tag ) {
581 continue; // Unclosed implicit tags between here and the match.
582 }
583 if ( empty( $stack ) && null !== $pending ) {
584 $pending['end'] = $cursor;
585 $children[] = $pending;
586 $pending = null;
587 }
588 }
589 return $children;
590 }
591
592 /**
593 * Whether stamping this section would break something inside it.
594 *
595 * `content-visibility: auto` applies layout, style and paint
596 * containment even while the section is on screen and rendered. Two
597 * kinds of content break under that:
598 *
599 * - an SVG `<use>`: paint containment breaks sprite references (WP
600 * Rocket skips these too);
601 * - an inline `position: fixed` or `sticky`: the section becomes the
602 * containing block, so a back-to-top button or a sticky bar is
603 * positioned against the section instead of the viewport.
604 *
605 * The span runs to the element's close tag. When element_end() can't
606 * pair it, the span runs to the next candidate (or `</body>` for the
607 * last one) instead: overshooting into a sibling only skips more, which
608 * is the safe direction. Read from the masked copy, so text in a script
609 * (`el.style.position = 'fixed'`) never counts. (#478)
610 *
611 * @param string $masked Markup with script/textarea/noscript/comments nulled.
612 * @param int $offset The candidate's `<`.
613 * @param int|null $span_end element_end() for this candidate.
614 * @param int[] $sorted Every candidate offset, ascending.
615 * @param int $doc_end Offset of `</body>`, or the buffer length.
616 */
617 private static function breaks_under_containment( string $masked, int $offset, ?int $span_end, array $sorted, int $doc_end ): bool {
618 $stop = $span_end;
619 if ( null === $stop ) {
620 $stop = $doc_end;
621 foreach ( $sorted as $next ) {
622 if ( $next > $offset ) {
623 $stop = $next;
624 break;
625 }
626 }
627 }
628 $span = substr( $masked, $offset, max( 0, $stop - $offset ) );
629 // A <style> element in the body often carries rules for other parts
630 // of the page (a sticky header rule printed by a page builder), so it
631 // says nothing about this section. Inline style="" attributes do.
632 $span = (string) preg_replace( '#<style\b[^>]*>.*?</style>#is', '', $span );
633
634 return (bool) preg_match( '#<use[\s/>]|position\s*:\s*(?:-webkit-)?(?:fixed|sticky)\b#i', $span );
635 }
636
637 /**
638 * The offset just past an element's close tag — the same tag walk as
639 * element_children(), seeded with the element's own tag, so implicitly
640 * closed tags don't desync it. Null when the element never closes (or
641 * the markup is too broken to tell), in which case the caller keeps
642 * the old behavior rather than guessing at a span.
643 *
644 * @param string $masked Markup with script/textarea/noscript/comments nulled.
645 * @param int $offset Byte offset of the element's `<`.
646 * @return int|null
647 */
648 private static function element_end( string $masked, int $offset ): ?int {
649 static $void = array( 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr' );
650 static $implicit = array( 'p', 'li', 'dt', 'dd', 'td', 'th', 'tr', 'option', 'optgroup' );
651
652 if ( ! preg_match( '#\G<([a-zA-Z][a-zA-Z0-9-]*)#', $masked, $open, 0, $offset ) ) {
653 return null;
654 }
655 $cursor = self::tag_end( $masked, $offset );
656 if ( null === $cursor ) {
657 return null;
658 }
659 $stack = array( strtolower( (string) $open[1] ) );
660 while ( preg_match( '#<(/?)([a-zA-Z][a-zA-Z0-9-]*)((?:"[^"]*"|\'[^\']*\'|[^>"\'])*)>#', $masked, $t, PREG_OFFSET_CAPTURE, $cursor ) ) {
661 $cursor = (int) $t[0][1] + strlen( (string) $t[0][0] );
662 $closing = '' !== $t[1][0];
663 $tag = strtolower( (string) $t[2][0] );
664 if ( in_array( $tag, $void, true ) ) {
665 continue;
666 }
667 if ( ! $closing ) {
668 if ( in_array( $tag, $implicit, true ) && end( $stack ) === $tag ) {
669 array_pop( $stack );
670 }
671 $stack[] = $tag;
672 continue;
673 }
674 if ( ! in_array( $tag, $stack, true ) ) {
675 if ( in_array( $tag, $implicit, true ) ) {
676 continue; // Stray </p>-style close: harmless, skip it.
677 }
678 return null; // A close for an ancestor: the element never closed.
679 }
680 while ( ! empty( $stack ) && array_pop( $stack ) !== $tag ) {
681 continue;
682 }
683 if ( empty( $stack ) ) {
684 return $cursor;
685 }
686 }
687 return null;
688 }
689
690 /**
691 * The offset just past an open tag's `>`, quote-aware — a raw strpos
692 * would stop at a `>` inside an attribute value.
693 *
694 * @param string $masked Markup with script/textarea/noscript/comments nulled.
695 * @param int $offset Byte offset of the tag's `<`.
696 * @return int|null Null when the tag never terminates.
697 */
698 private static function tag_end( string $masked, int $offset ): ?int {
699 if ( ! preg_match( '#\G<(/?)[a-zA-Z][a-zA-Z0-9-]*(?:"[^"]*"|\'[^\']*\'|[^>"\'])*>#', $masked, $m, 0, $offset ) ) {
700 return null;
701 }
702 return $offset + strlen( $m[0] );
703 }
704
705 /**
706 * Make the images inside stamped sections lazy.
707 *
708 * A stamped section is below the fold by Turbo Render's own count, so
709 * loading="eager" there buys nothing: the Lazy module's eager slot had
710 * gone to the first image of a slider 5,600px down. Left alone: an
711 * image marked fetchpriority="high", data-skip-lazy or data-no-lazy,
712 * one matching Lazy's Excluded Images, and any loading value other
713 * than eager.
714 */
715 private static function lazy_section_images( string $html ): string {
716 $masked = self::mask( $html );
717 if ( ! preg_match_all( '#<[a-zA-Z][a-zA-Z0-9-]*\s+data-xspeed-turbo=""#', $masked, $opens, PREG_OFFSET_CAPTURE ) ) {
718 return $html;
719 }
720 $spans = array();
721 $until = -1;
722 foreach ( $opens[0] as $open ) {
723 $start = (int) $open[1];
724 if ( $start < $until ) {
725 continue; // Nested inside a span already taken.
726 }
727 $end = self::element_end( $masked, $start );
728 if ( null === $end ) {
729 continue;
730 }
731 $spans[] = array( $start, $end );
732 $until = $end;
733 }
734 if ( empty( $spans ) || ! preg_match_all( '#<img\b(?:"[^"]*"|\'[^\']*\'|[^>"\'])*>#i', $masked, $imgs, PREG_OFFSET_CAPTURE ) ) {
735 return $html;
736 }
737
738 $lazy = \XSpeed\Settings_Manager::get( 'lazy' );
739 $excluded = is_array( $lazy ) && is_array( $lazy['excluded_images'] ?? null ) ? $lazy['excluded_images'] : array();
740
741 $edits = array();
742 foreach ( $imgs[0] as $img ) {
743 $at = (int) $img[1];
744 $in = false;
745 foreach ( $spans as $span ) {
746 if ( $at > $span[0] && $at < $span[1] ) {
747 $in = true;
748 break;
749 }
750 }
751 if ( ! $in ) {
752 continue;
753 }
754 $tag = substr( $html, $at, strlen( (string) $img[0] ) );
755 $new = self::lazy_img( $tag, $excluded );
756 if ( $new !== $tag ) {
757 $edits[] = array( $at, strlen( $tag ), $new );
758 }
759 }
760 foreach ( array_reverse( $edits ) as $edit ) {
761 $html = substr_replace( $html, $edit[2], $edit[0], $edit[1] );
762 }
763 return $html;
764 }
765
766 /**
767 * @param string $tag One <img> tag.
768 * @param string[] $excluded Lazy's Excluded Images patterns.
769 */
770 private static function lazy_img( string $tag, array $excluded ): string {
771 if ( false !== stripos( $tag, 'data-skip-lazy' ) || false !== stripos( $tag, 'data-no-lazy' )
772 || \XSpeed\Lazy_Loader::has_high_fetchpriority( $tag ) ) {
773 return $tag;
774 }
775 foreach ( $excluded as $pattern ) {
776 $pattern = (string) $pattern;
777 if ( '' !== $pattern && false !== stripos( $tag, $pattern ) ) {
778 return $tag;
779 }
780 }
781 if ( preg_match( '#(?<![-\w])loading\s*=\s*(["\']?)([^"\'\s>]*)\1#i', $tag, $m, PREG_OFFSET_CAPTURE ) ) {
782 if ( 'eager' !== strtolower( (string) $m[2][0] ) ) {
783 return $tag;
784 }
785 return substr_replace( $tag, 'loading="lazy"', (int) $m[0][1], strlen( (string) $m[0][0] ) );
786 }
787 return (string) preg_replace( '#^<img\b#i', '<img loading="lazy"', $tag, 1 );
788 }
789
790 private static function mask( string $html ): string {
791 return (string) preg_replace_callback(
792 '#' . self::MASKED_SPANS . '#is',
793 static fn( array $m ): string => str_repeat( "\0", strlen( $m[0] ) ),
794 $html
795 );
796 }
797
798 public function cli_commands(): array {
799 return array(
800 array(
801 'name' => 'xspeed turbo-render status',
802 'callback' => array( $this, 'cli_status' ),
803 'shortdesc' => 'Show Turbo Render status.',
804 '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.',
805 'synopsis' => array(),
806 ),
807 );
808 }
809
810 /**
811 * `wp xspeed turbo-render status`.
812 *
813 * @param array $args Positional args (unused).
814 * @param array $assoc Associative args (unused).
815 */
816 public function cli_status( array $args, array $assoc ): void {
817 unset( $args, $assoc );
818 $enabled = (bool) $this->get_setting( 'enabled', false );
819 \WP_CLI::log( 'Turbo Render: ' . ( $enabled ? 'enabled' : 'disabled' ) );
820 \WP_CLI::log( 'Immediate sections: first ' . (int) $this->get_setting( 'skip_first', self::DEFAULT_SKIP_FIRST ) . ' sections' );
821 \WP_CLI::log( 'Section classes: ' . implode( ', ', $this->section_classes() ) );
822 $excluded = $this->excluded_classes();
823 \WP_CLI::log( 'Excluded classes: ' . ( empty( $excluded ) ? '(none)' : implode( ', ', $excluded ) ) );
824 }
825 }
826