PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.6
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.6
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 1.1.3 1.1.4 1.1.5 All 32 releases
xspeed / includes / modules / TurboRender / TurboRenderModule.php

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

556 lines 21.7 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 private const MASKED_SPANS = '<script\b[^>]*>.*?</script>|<textarea\b[^>]*>.*?</textarea>|<noscript\b[^>]*>.*?</noscript>|<!--.*?-->';
80
81 /** Fallback: a child spanning less bytes than this is decoration (an empty notices div, a spacer), not a section. */
82 private const FALLBACK_MIN_SPAN = 150;
83
84 /** Fallback: a child holding this share of its siblings' combined bytes is a wrapper to unwrap, not a section. */
85 private const FALLBACK_DOMINANT = 0.6;
86
87 public function ui_metadata(): array {
88 return array(
89 'label' => __( 'Turbo Render', 'xspeed' ),
90 'icon' => 'Layers',
91 'description' => __( 'Paints the top of your page first and brings the rest in as visitors scroll. Every section still loads in full.', 'xspeed' ),
92 );
93 }
94
95 public function settings_schema(): array {
96 return array(
97 'enabled' => array(
98 'type' => 'bool',
99 'default' => false,
100 'label' => __( 'Render as visitors scroll', 'xspeed' ),
101 'description' => __( 'Lets the browser spend its first moments on what visitors actually see, then render the lower sections just before they scroll into view. The content of your page is unchanged — it simply arrives in a smarter order.', 'xspeed' ),
102 ),
103 'skip_first' => array(
104 'type' => 'int',
105 'default' => self::DEFAULT_SKIP_FIRST,
106 'min' => 1,
107 'max' => 10,
108 'label' => __( 'Sections to render immediately', 'xspeed' ),
109 'description' => __( 'How many sections at the top of the page are rendered right away, before any deferring begins. The default of 2 suits most layouts. Increase it if a section near the top of your page appears a moment late.', 'xspeed' ),
110 ),
111 'section_classes' => array(
112 'type' => 'list',
113 'default' => self::DEFAULT_CLASSES,
114 'label' => __( 'Section classes', 'xspeed' ),
115 'description' => __( 'Tells xSpeed which parts of your page count as sections. The defaults cover Elementor, Divi, Bricks, Oxygen, and Beaver Builder — and when none of them match, xSpeed falls back to your page\'s own top-level sections automatically. Add a class here only if you want to target something specific.', 'xspeed' ),
116 ),
117 'excluded_classes' => array(
118 'type' => 'list',
119 'default' => array(),
120 'label' => __( 'Excluded classes', 'xspeed' ),
121 'description' => __( 'Sections with any of these classes always render right away. Use this when a design overlaps its neighbours — a card that hangs over the section below it gets cut off at the boundary when either section is deferred.', 'xspeed' ),
122 ),
123 );
124 }
125
126 public function boot(): void {
127 if ( is_admin() || wp_doing_ajax() || wp_doing_cron() ) {
128 return;
129 }
130 // Deferred to `init`: reading the enabled flag builds
131 // settings_schema(), whose labels go through __(), and boot() runs
132 // on plugins_loaded — before WP 6.7 considers translation loading
133 // safe (same reasoning as BloatModule::boot()).
134 add_action( 'init', array( $this, 'boot_on_init' ) );
135 }
136
137 /** The real boot body — see boot() for why it runs on `init`. */
138 public function boot_on_init(): void {
139 if ( ! $this->get_setting( 'enabled', false ) ) {
140 return;
141 }
142 // After the CSS passes (combiner @5, a Pro CSS pass @6): they rewrite
143 // <head>, this pass rewrites body sections — ordering only matters in
144 // that our injected <style> must survive, and later passes never
145 // strip inline styles.
146 add_filter( 'xspeed_cache_final_html', array( $this, 'process' ), 8 );
147 }
148
149 /**
150 * Stamp below-fold top-level sections and inject the one style block.
151 *
152 * @param mixed $html Final page buffer.
153 * @return mixed
154 */
155 public function process( $html ) {
156 if ( ! is_string( $html ) || '' === $html ) {
157 return $html;
158 }
159 if ( false === stripos( $html, '</head>' ) ) {
160 return $html; // Not a full document (fragment, feed, JSON).
161 }
162 if ( function_exists( 'is_user_logged_in' ) && is_user_logged_in() ) {
163 return $html;
164 }
165 // A measurement fetch (`?xspeed_css=off` — the render a CSS
166 // generator reads) must see every section RENDERED. Shipped without
167 // this guard, a renderer measured a page whose below-fold sections
168 // the browser had skipped, judged their CSS unused, and pruned it —
169 // mobile CLS went 0.00 → 0.37 because the fold's own sizing rules
170 // were gone. The param is a shared contract (a Pro CSS module
171 // defines it), matched here by name because Free never references
172 // Pro code.
173 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only bypass detection; changes nothing.
174 if ( isset( $_GET['xspeed_css'] ) && 'off' === sanitize_text_field( wp_unslash( $_GET['xspeed_css'] ) ) ) {
175 return $html;
176 }
177
178 $classes = $this->section_classes();
179
180 /**
181 * Filter how many top-level sections stay untouched as above-fold.
182 *
183 * @param int $skip_first
184 */
185 $skip = max( 1, (int) apply_filters( 'xspeed_turbo_render_after', (int) $this->get_setting( 'skip_first', self::DEFAULT_SKIP_FIRST ) ) );
186
187 $masked = self::mask( $html );
188 $offsets = array();
189 $pattern = '#<(?:section|div|footer|main|article)\b[^>]*\bclass\s*=\s*(["\'])[^"\']*(?:' . implode( '|', array_map( 'preg_quote', $classes ) ) . ')[^"\']*\1[^>]*>#i';
190 if ( ! empty( $classes ) && preg_match_all( $pattern, $masked, $m, PREG_OFFSET_CAPTURE ) ) {
191 foreach ( $m[0] as $hit ) {
192 $offsets[] = (int) $hit[1];
193 }
194 }
195
196 /**
197 * Filter whether the structural fallback runs when no known section
198 * class matches: the direct children of <main> are treated as the
199 * page's sections, which is what makes Turbo Render work on block
200 * themes, classic themes, and builders not in the class list.
201 *
202 * @param bool $enabled
203 */
204 if ( empty( $offsets ) && apply_filters( 'xspeed_turbo_render_fallback', true ) ) {
205 $offsets = self::main_children( $masked, $skip );
206 }
207
208 $excluded = $this->excluded_pattern();
209
210 $seen = 0;
211 $edits = array();
212 foreach ( $offsets as $offset ) {
213 ++$seen;
214 if ( $seen <= $skip ) {
215 continue;
216 }
217 $end = self::tag_end( $masked, $offset );
218 if ( null === $end ) {
219 continue; // Unterminated tag at EOF — never stamp it.
220 }
221 $open_tag = substr( $html, $offset, $end - $offset );
222 if ( false !== stripos( $open_tag, 'data-xspeed-turbo' ) ) {
223 continue;
224 }
225 if ( '' !== $excluded && preg_match( $excluded, $open_tag ) ) {
226 continue; // Opted out — an overlap design the stamp would clip.
227 }
228 // `content-visibility` implies paint containment: it clips
229 // content that overhangs the section, and skipped iframes can
230 // blank or reload when the section re-renders. An embed holder
231 // (map, video) is never worth deferring — skip it. (Found live:
232 // a Kadence maps container painted over the card overlapping it.)
233 $span_end = self::element_end( $masked, $offset );
234 if ( null !== $span_end && false !== stripos( substr( $masked, $offset, $span_end - $offset ), '<iframe' ) ) {
235 continue;
236 }
237 $edits[] = $offset;
238 }
239
240 if ( empty( $edits ) ) {
241 return $html;
242 }
243
244 // Highest offset first, so earlier offsets stay valid as we splice.
245 // The attribute goes right after the tag name — the one spot
246 // guaranteed not to sit inside another attribute's value.
247 rsort( $edits );
248 foreach ( $edits as $offset ) {
249 $gap = (int) strcspn( $html, " \t\r\n/>", $offset + 1 );
250 $html = substr_replace( $html, ' data-xspeed-turbo=""', $offset + 1 + $gap, 0 );
251 }
252
253 /**
254 * Filter the pre-first-render intrinsic size estimate, in pixels.
255 *
256 * @param int $px
257 */
258 $px = max( 100, (int) apply_filters( 'xspeed_turbo_render_intrinsic_px', self::DEFAULT_INTRINSIC_PX ) );
259 $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>';
260
261 $head_end = stripos( $html, '</head>' );
262 return substr_replace( $html, $style, (int) $head_end, 0 );
263 }
264
265 /** @return string[] */
266 private function section_classes(): array {
267 $classes = $this->get_setting( 'section_classes', self::DEFAULT_CLASSES );
268 $classes = is_array( $classes ) ? array_values( array_filter( array_map( 'strval', $classes ) ) ) : self::DEFAULT_CLASSES;
269
270 /**
271 * Filter the class names identifying a top-level page section.
272 *
273 * @param string[] $classes
274 */
275 return self::sanitize_classes( (array) apply_filters( 'xspeed_turbo_render_classes', $classes ) );
276 }
277
278 /** @return string[] */
279 private function excluded_classes(): array {
280 $classes = $this->get_setting( 'excluded_classes', array() );
281 $classes = is_array( $classes ) ? $classes : array();
282
283 /**
284 * Filter the class names whose sections are never stamped.
285 *
286 * @param string[] $classes
287 */
288 return self::sanitize_classes( (array) apply_filters( 'xspeed_turbo_render_excluded_classes', $classes ) );
289 }
290
291 /**
292 * The exclusion regex for an open tag, or '' when nothing is excluded.
293 * Token-bounded, unlike the section scan: an exclusion is a user-typed
294 * remedy, and "card" silently matching "cardigan-grid" would make it
295 * look like the setting does nothing.
296 */
297 private function excluded_pattern(): string {
298 $classes = $this->excluded_classes();
299 if ( empty( $classes ) ) {
300 return '';
301 }
302 return '#\bclass\s*=\s*(["\'])[^"\']*(?<![A-Za-z0-9_-])(?:'
303 . implode( '|', array_map( 'preg_quote', $classes ) )
304 . ')(?![A-Za-z0-9_-])[^"\']*\1#i';
305 }
306
307 /**
308 * Class names end up inside a regex alternation; anything that is not a
309 * plausible CSS class token is dropped rather than escaped into
310 * something surprising.
311 *
312 * @param string[] $classes
313 * @return string[]
314 */
315 private static function sanitize_classes( array $classes ): array {
316 return array_values(
317 array_filter(
318 array_map( 'strval', $classes ),
319 static fn( string $c ): bool => (bool) preg_match( '/^[A-Za-z0-9_-]+$/', $c )
320 )
321 );
322 }
323
324 /**
325 * Structural fallback: the byte offsets of <main>'s section children.
326 *
327 * Themes love wrapper chains — Kadence renders <main> > a hero
328 * <section> + an empty notices <div> + one wrapper <div> holding
329 * everything else four levels deep. Two rules recover the real
330 * sections: drop tiny children (decoration, not layout), and while the
331 * list is too short to stamp anything, unwrap — in place — a child
332 * that dominates its siblings by byte share. Only a <div>/<article>
333 * unwraps, and a dominant FIRST child only when it is the sole child:
334 * wrappers in the wild are divs, but so are many heroes, and opening a
335 * hero would stamp above-fold content. The cap only guards against
336 * pathological markup.
337 *
338 * @param string $masked Markup with script/textarea/noscript/comments nulled.
339 * @param int $skip The above-fold allowance in effect.
340 * @return int[]
341 */
342 private static function main_children( string $masked, int $skip ): array {
343 if ( ! preg_match( '#<main\b[^>]*>#i', $masked, $open, PREG_OFFSET_CAPTURE ) ) {
344 return array();
345 }
346 $children = self::element_children( $masked, (int) $open[0][1] + strlen( (string) $open[0][0] ) );
347
348 for ( $level = 0; $level < 10; $level++ ) {
349 $children = array_values(
350 array_filter( $children, static fn( array $c ): bool => ( $c['end'] - $c['start'] ) >= self::FALLBACK_MIN_SPAN )
351 );
352 if ( count( $children ) > $skip || empty( $children ) ) {
353 break;
354 }
355 $total = array_sum( array_map( static fn( array $c ): int => $c['end'] - $c['start'], $children ) );
356 $unwrapped = false;
357 foreach ( $children as $i => $child ) {
358 if ( ! in_array( $child['tag'], array( 'div', 'article' ), true ) ) {
359 continue;
360 }
361 if ( 0 === $i && count( $children ) > 1 ) {
362 continue; // A dominant first child among siblings is a hero, not a wrapper.
363 }
364 if ( ( $child['end'] - $child['start'] ) < self::FALLBACK_DOMINANT * $total ) {
365 continue;
366 }
367 $end = self::tag_end( $masked, $child['start'] );
368 if ( null === $end ) {
369 break 2;
370 }
371 $inner = self::element_children( $masked, $end );
372 if ( empty( $inner ) ) {
373 break 2;
374 }
375 array_splice( $children, $i, 1, $inner );
376 $unwrapped = true;
377 break;
378 }
379 if ( ! $unwrapped ) {
380 break;
381 }
382 }
383 return array_map( static fn( array $c ): int => $c['start'], $children );
384 }
385
386 /**
387 * Byte spans of an element's direct section-shaped children.
388 *
389 * A tag walk with an open-tag stack, not a depth counter, so the HTML5
390 * that classic themes actually emit doesn't desync it: implicitly
391 * closed tags (<li>a<li>b, an unclosed <p>) are popped by the matching
392 * ancestor close, a stray close of an implicit tag is ignored, and a
393 * trailing slash on a non-void tag is meaningless (HTML5) so <div/> is
394 * an OPEN div. A close tag for anything not on the stack ends the walk
395 * — normally the container's own close. Only section-shaped tags count
396 * as children: stamping a stray <p> or <h2> would put an 800px
397 * intrinsic-size estimate on a one-line element and wreck the
398 * scrollbar.
399 *
400 * @param string $masked Markup with script/textarea/noscript/comments nulled.
401 * @param int $cursor Byte offset just past the container's open tag.
402 * @return array<int, array{start:int, end:int, tag:string}>
403 */
404 private static function element_children( string $masked, int $cursor ): array {
405 static $void = array( 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr' );
406 static $want = array( 'div', 'section', 'article', 'footer', 'aside', 'figure', 'table', 'ul', 'ol' );
407 static $implicit = array( 'p', 'li', 'dt', 'dd', 'td', 'th', 'tr', 'option', 'optgroup' );
408
409 $children = array();
410 $pending = null;
411 $stack = array();
412 while ( preg_match( '#<(/?)([a-zA-Z][a-zA-Z0-9-]*)((?:"[^"]*"|\'[^\']*\'|[^>"\'])*)>#', $masked, $t, PREG_OFFSET_CAPTURE, $cursor ) ) {
413 $offset = (int) $t[0][1];
414 $cursor = $offset + strlen( (string) $t[0][0] );
415 $closing = '' !== $t[1][0];
416 $tag = strtolower( (string) $t[2][0] );
417 if ( in_array( $tag, $void, true ) ) {
418 continue;
419 }
420 if ( ! $closing ) {
421 if ( in_array( $tag, $implicit, true ) && end( $stack ) === $tag ) {
422 array_pop( $stack ); // A sibling <li>/<p>/<td> implicitly closes the previous one.
423 }
424 if ( empty( $stack ) && in_array( $tag, $want, true ) ) {
425 $pending = array(
426 'start' => $offset,
427 'end' => $offset,
428 'tag' => $tag,
429 );
430 }
431 $stack[] = $tag;
432 continue;
433 }
434 if ( ! in_array( $tag, $stack, true ) ) {
435 if ( in_array( $tag, $implicit, true ) ) {
436 continue; // Stray </p>-style close: harmless, skip it.
437 }
438 break; // The container's own close tag: the walk is done.
439 }
440 while ( ! empty( $stack ) && array_pop( $stack ) !== $tag ) {
441 continue; // Unclosed implicit tags between here and the match.
442 }
443 if ( empty( $stack ) && null !== $pending ) {
444 $pending['end'] = $cursor;
445 $children[] = $pending;
446 $pending = null;
447 }
448 }
449 return $children;
450 }
451
452 /**
453 * The offset just past an element's close tag — the same tag walk as
454 * element_children(), seeded with the element's own tag, so implicitly
455 * closed tags don't desync it. Null when the element never closes (or
456 * the markup is too broken to tell), in which case the caller keeps
457 * the old behavior rather than guessing at a span.
458 *
459 * @param string $masked Markup with script/textarea/noscript/comments nulled.
460 * @param int $offset Byte offset of the element's `<`.
461 * @return int|null
462 */
463 private static function element_end( string $masked, int $offset ): ?int {
464 static $void = array( 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr' );
465 static $implicit = array( 'p', 'li', 'dt', 'dd', 'td', 'th', 'tr', 'option', 'optgroup' );
466
467 if ( ! preg_match( '#\G<([a-zA-Z][a-zA-Z0-9-]*)#', $masked, $open, 0, $offset ) ) {
468 return null;
469 }
470 $cursor = self::tag_end( $masked, $offset );
471 if ( null === $cursor ) {
472 return null;
473 }
474 $stack = array( strtolower( (string) $open[1] ) );
475 while ( preg_match( '#<(/?)([a-zA-Z][a-zA-Z0-9-]*)((?:"[^"]*"|\'[^\']*\'|[^>"\'])*)>#', $masked, $t, PREG_OFFSET_CAPTURE, $cursor ) ) {
476 $cursor = (int) $t[0][1] + strlen( (string) $t[0][0] );
477 $closing = '' !== $t[1][0];
478 $tag = strtolower( (string) $t[2][0] );
479 if ( in_array( $tag, $void, true ) ) {
480 continue;
481 }
482 if ( ! $closing ) {
483 if ( in_array( $tag, $implicit, true ) && end( $stack ) === $tag ) {
484 array_pop( $stack );
485 }
486 $stack[] = $tag;
487 continue;
488 }
489 if ( ! in_array( $tag, $stack, true ) ) {
490 if ( in_array( $tag, $implicit, true ) ) {
491 continue; // Stray </p>-style close: harmless, skip it.
492 }
493 return null; // A close for an ancestor: the element never closed.
494 }
495 while ( ! empty( $stack ) && array_pop( $stack ) !== $tag ) {
496 continue;
497 }
498 if ( empty( $stack ) ) {
499 return $cursor;
500 }
501 }
502 return null;
503 }
504
505 /**
506 * The offset just past an open tag's `>`, quote-aware — a raw strpos
507 * would stop at a `>` inside an attribute value.
508 *
509 * @param string $masked Markup with script/textarea/noscript/comments nulled.
510 * @param int $offset Byte offset of the tag's `<`.
511 * @return int|null Null when the tag never terminates.
512 */
513 private static function tag_end( string $masked, int $offset ): ?int {
514 if ( ! preg_match( '#\G<(/?)[a-zA-Z][a-zA-Z0-9-]*(?:"[^"]*"|\'[^\']*\'|[^>"\'])*>#', $masked, $m, 0, $offset ) ) {
515 return null;
516 }
517 return $offset + strlen( $m[0] );
518 }
519
520 private static function mask( string $html ): string {
521 return (string) preg_replace_callback(
522 '#' . self::MASKED_SPANS . '#is',
523 static fn( array $m ): string => str_repeat( "\0", strlen( $m[0] ) ),
524 $html
525 );
526 }
527
528 public function cli_commands(): array {
529 return array(
530 array(
531 'name' => 'xspeed turbo-render status',
532 'callback' => array( $this, 'cli_status' ),
533 'shortdesc' => 'Show Turbo Render status.',
534 '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.',
535 'synopsis' => array(),
536 ),
537 );
538 }
539
540 /**
541 * `wp xspeed turbo-render status`.
542 *
543 * @param array $args Positional args (unused).
544 * @param array $assoc Associative args (unused).
545 */
546 public function cli_status( array $args, array $assoc ): void {
547 unset( $args, $assoc );
548 $enabled = (bool) $this->get_setting( 'enabled', false );
549 \WP_CLI::log( 'Turbo Render: ' . ( $enabled ? 'enabled' : 'disabled' ) );
550 \WP_CLI::log( 'Immediate sections: first ' . (int) $this->get_setting( 'skip_first', self::DEFAULT_SKIP_FIRST ) . ' sections' );
551 \WP_CLI::log( 'Section classes: ' . implode( ', ', $this->section_classes() ) );
552 $excluded = $this->excluded_classes();
553 \WP_CLI::log( 'Excluded classes: ' . ( empty( $excluded ) ? '(none)' : implode( ', ', $excluded ) ) );
554 }
555 }
556