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

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

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