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
← All changes | includes/modules/TurboRender/TurboRenderModule.php +126 -9 1.3.5 → 1.3.7 View file →
@@ -87,9 +87,10 @@
87 87 public function ui_metadata(): array {
88 88 return array(
89 89 'label' => __( 'Turbo Render', 'xspeed' ),
90 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' ),
91 + 'description' => __( 'Shows the top of each page first and the rest as visitors scroll.', 'xspeed' ),
92 + 'group' => 'performance',
92 93 );
93 94 }
94 95
95 96 public function settings_schema(): array {
@@ -97,9 +98,9 @@
97 98 'enabled' => array(
98 99 'type' => 'bool',
99 100 'default' => false,
100 101 '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 + 'description' => __( 'The browser draws what visitors see first and draws lower sections just before they scroll to them. Your content does not change.', 'xspeed' ),
102 103 ),
103 104 'skip_first' => array(
104 105 'type' => 'int',
105 106 'default' => self::DEFAULT_SKIP_FIRST,
@@ -105,16 +106,26 @@
105 106 'default' => self::DEFAULT_SKIP_FIRST,
106 107 'min' => 1,
107 108 'max' => 10,
108 109 '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 + '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' ),
110 112 ),
111 113 'section_classes' => array(
112 114 'type' => 'list',
113 115 'default' => self::DEFAULT_CLASSES,
114 116 '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' ),
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' ),
116 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 + ),
117 128 );
118 129 }
119 130
120 131 public function boot(): void {
@@ -198,8 +209,10 @@
198 209 if ( empty( $offsets ) && apply_filters( 'xspeed_turbo_render_fallback', true ) ) {
199 210 $offsets = self::main_children( $masked, $skip );
200 211 }
201 212
213 + $excluded = $this->excluded_pattern();
214 +
202 215 $seen = 0;
203 216 $edits = array();
204 217 foreach ( $offsets as $offset ) {
205 218 ++$seen;
@@ -209,11 +222,24 @@
209 222 $end = self::tag_end( $masked, $offset );
210 223 if ( null === $end ) {
211 224 continue; // Unterminated tag at EOF — never stamp it.
212 225 }
213 - if ( false !== stripos( substr( $html, $offset, $end - $offset ), 'data-xspeed-turbo' ) ) {
226 + $open_tag = substr( $html, $offset, $end - $offset );
227 + if ( false !== stripos( $open_tag, 'data-xspeed-turbo' ) ) {
214 228 continue;
215 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 + }
216 242 $edits[] = $offset;
217 243 }
218 244
219 245 if ( empty( $edits ) ) {
@@ -250,13 +276,49 @@
250 276 * Filter the class names identifying a top-level page section.
251 277 *
252 278 * @param string[] $classes
253 279 */
254 - $classes = (array) apply_filters( 'xspeed_turbo_render_classes', $classes );
280 + return self::sanitize_classes( (array) apply_filters( 'xspeed_turbo_render_classes', $classes ) );
281 + }
255 282
256 - // Class names end up inside a regex alternation; anything that is
257 - // not a plausible CSS class token is dropped rather than escaped
258 - // into something surprising.
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 {
259 321 return array_values(
260 322 array_filter(
261 323 array_map( 'strval', $classes ),
262 324 static fn( string $c ): bool => (bool) preg_match( '/^[A-Za-z0-9_-]+$/', $c )
@@ -392,8 +454,61 @@
392 454 return $children;
393 455 }
394 456
395 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 + /**
396 511 * The offset just past an open tag's `>`, quote-aware — a raw strpos
397 512 * would stop at a `>` inside an attribute value.
398 513 *
399 514 * @param string $masked Markup with script/textarea/noscript/comments nulled.
@@ -438,6 +553,8 @@
438 553 $enabled = (bool) $this->get_setting( 'enabled', false );
439 554 \WP_CLI::log( 'Turbo Render: ' . ( $enabled ? 'enabled' : 'disabled' ) );
440 555 \WP_CLI::log( 'Immediate sections: first ' . (int) $this->get_setting( 'skip_first', self::DEFAULT_SKIP_FIRST ) . ' sections' );
441 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 ) ) );
442 559 }
443 560 }