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
← All changes | includes/modules/TurboRender/TurboRenderModule.php +117 -5 1.3.5 → 1.3.6 View file →
@@ -113,8 +113,14 @@
113 113 'default' => self::DEFAULT_CLASSES,
114 114 'label' => __( 'Section classes', 'xspeed' ),
115 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 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 + ),
117 123 );
118 124 }
119 125
120 126 public function boot(): void {
@@ -198,8 +204,10 @@
198 204 if ( empty( $offsets ) && apply_filters( 'xspeed_turbo_render_fallback', true ) ) {
199 205 $offsets = self::main_children( $masked, $skip );
200 206 }
201 207
208 + $excluded = $this->excluded_pattern();
209 +
202 210 $seen = 0;
203 211 $edits = array();
204 212 foreach ( $offsets as $offset ) {
205 213 ++$seen;
@@ -209,11 +217,24 @@
209 217 $end = self::tag_end( $masked, $offset );
210 218 if ( null === $end ) {
211 219 continue; // Unterminated tag at EOF — never stamp it.
212 220 }
213 - if ( false !== stripos( substr( $html, $offset, $end - $offset ), 'data-xspeed-turbo' ) ) {
221 + $open_tag = substr( $html, $offset, $end - $offset );
222 + if ( false !== stripos( $open_tag, 'data-xspeed-turbo' ) ) {
214 223 continue;
215 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 + }
216 237 $edits[] = $offset;
217 238 }
218 239
219 240 if ( empty( $edits ) ) {
@@ -250,13 +271,49 @@
250 271 * Filter the class names identifying a top-level page section.
251 272 *
252 273 * @param string[] $classes
253 274 */
254 - $classes = (array) apply_filters( 'xspeed_turbo_render_classes', $classes );
275 + return self::sanitize_classes( (array) apply_filters( 'xspeed_turbo_render_classes', $classes ) );
276 + }
255 277
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.
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 {
259 316 return array_values(
260 317 array_filter(
261 318 array_map( 'strval', $classes ),
262 319 static fn( string $c ): bool => (bool) preg_match( '/^[A-Za-z0-9_-]+$/', $c )
@@ -392,8 +449,61 @@
392 449 return $children;
393 450 }
394 451
395 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 + /**
396 506 * The offset just past an open tag's `>`, quote-aware — a raw strpos
397 507 * would stop at a `>` inside an attribute value.
398 508 *
399 509 * @param string $masked Markup with script/textarea/noscript/comments nulled.
@@ -438,6 +548,8 @@
438 548 $enabled = (bool) $this->get_setting( 'enabled', false );
439 549 \WP_CLI::log( 'Turbo Render: ' . ( $enabled ? 'enabled' : 'disabled' ) );
440 550 \WP_CLI::log( 'Immediate sections: first ' . (int) $this->get_setting( 'skip_first', self::DEFAULT_SKIP_FIRST ) . ' sections' );
441 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 ) ) );
442 554 }
443 555 }