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/class-resource-hints-processor.php +257 -0 1.3.0 → 1.3.6 View file →
@@ -328,8 +328,26 @@
328 328 foreach ( self::background_candidates( $html, $exclusions, $skip_ranges ) as $bg ) {
329 329 $candidates[] = $bg;
330 330 }
331 331
332 + // PASS 1c — <video poster="…">. A full-screen hero video paints its
333 + // poster first, and that first frame IS the LCP; measured on a live
334 + // page, a preloaded poster cut the LCP load delay from 1.5 s to 21 ms.
335 + foreach ( self::video_poster_candidates( $html, $exclusions, $skip_ranges ) as $vp ) {
336 + $candidates[] = $vp;
337 + }
338 +
339 + // PASS 1d — background rules in inline <style> blocks. Page builders
340 + // put the hero's background-image in generated per-post CSS printed
341 + // inline (Elementor's `.elementor-N .elementor-element-X` rules), not
342 + // in a style attribute — so PASS 1b never saw the element that
343 + // actually paints as LCP on five of seven measured sites. External
344 + // stylesheets stay out for the same reasons as before (#247): fetching
345 + // CSS from an output-buffer pass costs more than the preload saves.
346 + foreach ( self::style_block_candidates( $html, $exclusions, $skip_ranges ) as $sb ) {
347 + $candidates[] = $sb;
348 + }
349 +
332 350 if ( empty( $candidates ) ) {
333 351 return array( $html, '' );
334 352 }
335 353
@@ -474,8 +492,247 @@
474 492 );
475 493 }
476 494
477 495 return $found;
496 + }
497 +
498 + /**
499 + * Collect `<video poster="…">` first frames as LCP candidates.
500 + *
501 + * The poster is what the viewer sees until (and unless) the video plays —
502 + * on a background-video hero, delayed by the Lazy module, it is the ONLY
503 + * frame the initial paint has. Scored like a background: the element's
504 + * declared inline-style area, or the unknown-size floor, with the order
505 + * offset past every <img> so a poster never ties ahead of one.
506 + *
507 + * @param string $html Full page HTML.
508 + * @param string[] $exclusions Substring patterns the user excluded.
509 + * @param array<int,array{0:int,1:int}> $skip_ranges Byte ranges of chrome containers.
510 + * @return array<int,array{tag:string,src:string,srcset:string,sizes:string,score:float,order:int,background:bool}>
511 + */
512 + private static function video_poster_candidates( string $html, array $exclusions, array $skip_ranges ): array {
513 + if ( ! preg_match_all( '#<video\b[^>]*\bposter\s*=\s*(["\'])(.*?)\1[^>]*>#i', $html, $matches, PREG_OFFSET_CAPTURE ) ) {
514 + return array();
515 + }
516 +
517 + $found = array();
518 + foreach ( $matches[0] as $index => $match ) {
519 + [ $tag, $offset ] = $match;
520 + $src = trim( html_entity_decode( $matches[2][ $index ][0], ENT_QUOTES ) );
521 + if ( '' === $src || 0 === stripos( $src, 'data:' ) ) {
522 + continue;
523 + }
524 + if ( self::offset_in_ranges( $offset, $skip_ranges ) ) {
525 + continue;
526 + }
527 + foreach ( $exclusions as $needle ) {
528 + if ( '' !== $needle && false !== stripos( $tag, $needle ) ) {
529 + continue 2;
530 + }
531 + }
532 + if ( self::looks_too_small( $tag ) ) {
533 + continue;
534 + }
535 +
536 + $area = self::style_area( self::attr( $tag, 'style' ) );
537 + if ( 0 === $area ) {
538 + $area = self::UNKNOWN_SIZE_SCORE;
539 + }
540 +
541 + $found[] = array(
542 + 'tag' => $tag,
543 + 'src' => $src,
544 + 'srcset' => '',
545 + 'sizes' => '',
546 + 'score' => (float) $area,
547 + 'order' => 100000 + $index,
548 + 'background' => true,
549 + );
550 + }
551 +
552 + return $found;
553 + }
554 +
555 + /**
556 + * How many <style>-block background rules are considered per page. The
557 + * scan is linear, but each rule costs one class-lookup pass over the
558 + * body, so a pathological page (thousands of generated rules) is capped
559 + * rather than trusted.
560 + */
561 + private const STYLE_RULE_BUDGET = 40;
562 +
563 + /**
564 + * Collect background-image rules from inline <style> blocks whose
565 + * selector matches an element in the body.
566 + *
567 + * The match is deliberately narrow: the rule's RIGHTMOST simple selector
568 + * must carry a class or id, and the first element in the body bearing it
569 + * (outside chrome containers) is taken as the painted element. Rules
570 + * inside @media (or any other at-rule block) are skipped — a desktop-only
571 + * background preloaded on mobile is a wasted high-priority fetch, and the
572 + * markup gives no viewport to resolve the query against.
573 + *
574 + * @param string $html Full page HTML.
575 + * @param string[] $exclusions Substring patterns the user excluded.
576 + * @param array<int,array{0:int,1:int}> $skip_ranges Byte ranges of chrome containers.
577 + * @return array<int,array{tag:string,src:string,srcset:string,sizes:string,score:float,order:int,background:bool}>
578 + */
579 + private static function style_block_candidates( string $html, array $exclusions, array $skip_ranges ): array {
580 + if ( ! preg_match_all( '#<style\b[^>]*>(.*?)</style\s*>#is', $html, $blocks ) ) {
581 + return array();
582 + }
583 +
584 + $found = array();
585 + $budget = self::STYLE_RULE_BUDGET;
586 + foreach ( $blocks[1] as $css ) {
587 + if ( $budget <= 0 ) {
588 + break;
589 + }
590 + $css = self::strip_at_rule_blocks( $css );
591 + if ( false === stripos( $css, 'url(' ) ) {
592 + continue;
593 + }
594 + // One flat rule at a time: selector list up to '{', body to '}'.
595 + if ( ! preg_match_all( '#(?:^|})\s*([^{}]{1,512})\{([^{}]*)\}#s', $css, $rules, PREG_SET_ORDER ) ) {
596 + continue;
597 + }
598 + foreach ( $rules as $rule ) {
599 + if ( $budget <= 0 ) {
600 + break 2;
601 + }
602 + if ( false === stripos( $rule[2], 'url(' ) ) {
603 + continue;
604 + }
605 + $src = self::background_url( $rule[2] );
606 + if ( '' === $src ) {
607 + continue;
608 + }
609 + --$budget;
610 + // First selector of the list, rightmost compound of it.
611 + $selector = trim( (string) strtok( $rule[1], ',' ) );
612 + $parts = preg_split( '#[\s>+~]+#', $selector );
613 + $last = (string) end( $parts );
614 + // The last class or id token of that compound. Pseudo-classes
615 + // (:hover, ::before) mean the background is not the initial
616 + // paint, so they disqualify the rule.
617 + if ( false !== strpos( $last, ':' ) ) {
618 + continue;
619 + }
620 + if ( ! preg_match( '#([.\#])([-\w]+)$#', $last, $tok ) ) {
621 + continue;
622 + }
623 + $el = '.' === $tok[1]
624 + ? self::first_element_with_class( $html, $tok[2], $skip_ranges )
625 + : self::first_element_with_id( $html, $tok[2], $skip_ranges );
626 + if ( null === $el ) {
627 + continue;
628 + }
629 + [ $tag, $offset ] = $el;
630 + foreach ( $exclusions as $needle ) {
631 + if ( '' !== $needle && false !== stripos( $tag, $needle ) ) {
632 + continue 2;
633 + }
634 + }
635 + if ( self::looks_too_small( $tag ) ) {
636 + continue;
637 + }
638 + $area = self::style_area( self::attr( $tag, 'style' ) );
639 + if ( 0 === $area ) {
640 + $area = self::UNKNOWN_SIZE_SCORE;
641 + }
642 + $found[] = array(
643 + 'tag' => $tag,
644 + 'src' => $src,
645 + 'srcset' => '',
646 + 'sizes' => '',
647 + 'score' => (float) $area,
648 + // Offset past the inline-style backgrounds: a rule-matched
649 + // background is one inference step less certain, so it must
650 + // never tie ahead of one read straight off the element.
651 + 'order' => 200000 + $offset,
652 + 'background' => true,
653 + );
654 + }
655 + }
656 +
657 + return $found;
658 + }
659 +
660 + /**
661 + * CSS with every at-rule BLOCK (@media, @supports, @container, …) removed,
662 + * by brace depth — a regex cannot pair nested braces. Flat at-rules
663 + * (@import, @charset) have no block and pass through harmlessly.
664 + */
665 + private static function strip_at_rule_blocks( string $css ): string {
666 + $out = '';
667 + $len = strlen( $css );
668 + $i = 0;
669 + while ( $i < $len ) {
670 + $at = strpos( $css, '@', $i );
671 + if ( false === $at ) {
672 + return $out . substr( $css, $i );
673 + }
674 + $brace = strpos( $css, '{', $at );
675 + $semi = strpos( $css, ';', $at );
676 + $out .= substr( $css, $i, $at - $i );
677 + if ( false === $brace || ( false !== $semi && $semi < $brace ) ) {
678 + // Flat at-rule — skip to its semicolon (or end).
679 + $i = false === $semi ? $len : $semi + 1;
680 + continue;
681 + }
682 + // Block at-rule — skip to its matching close brace.
683 + $depth = 1;
684 + $i = $brace + 1;
685 + while ( $i < $len && $depth > 0 ) {
686 + $c = $css[ $i ];
687 + if ( '{' === $c ) {
688 + ++$depth;
689 + } elseif ( '}' === $c ) {
690 + --$depth;
691 + }
692 + ++$i;
693 + }
694 + }
695 + return $out;
696 + }
697 +
698 + /**
699 + * The first element in the BODY carrying $class (outside chrome ranges),
700 + * as [tag, offset], or null. Body-only, so a head <meta> can never match
701 + * and a hit's offset is comparable with the chrome ranges.
702 + *
703 + * @return array{0:string,1:int}|null
704 + */
705 + private static function first_element_with_class( string $html, string $class, array $skip_ranges ): ?array {
706 + $body = stripos( $html, '<body' );
707 + $from = false === $body ? 0 : $body;
708 + if ( ! preg_match_all( '#<[a-z][^>]*\bclass\s*=\s*(["\'])[^"\']*(?<![-\w])' . preg_quote( $class, '#' ) . '(?![-\w])[^"\']*\1[^>]*>#i', $html, $m, PREG_OFFSET_CAPTURE, $from ) ) {
709 + return null;
710 + }
711 + foreach ( $m[0] as $match ) {
712 + if ( ! self::offset_in_ranges( $match[1], $skip_ranges ) ) {
713 + return array( $match[0], $match[1] );
714 + }
715 + }
716 + return null;
717 + }
718 +
719 + /**
720 + * The first element carrying id="$id" (outside chrome ranges), as
721 + * [tag, offset], or null.
722 + *
723 + * @return array{0:string,1:int}|null
724 + */
725 + private static function first_element_with_id( string $html, string $id, array $skip_ranges ): ?array {
726 + $body = stripos( $html, '<body' );
727 + $from = false === $body ? 0 : $body;
728 + if ( ! preg_match( '#<[a-z][^>]*\bid\s*=\s*(["\'])' . preg_quote( $id, '#' ) . '\1[^>]*>#i', $html, $m, PREG_OFFSET_CAPTURE, $from ) ) {
729 + return null;
730 + }
731 + if ( self::offset_in_ranges( $m[0][1], $skip_ranges ) ) {
732 + return null;
733 + }
734 + return array( $m[0][0], $m[0][1] );
478 735 }
479 736
480 737 /**
481 738 * Pull a real image URL out of a `background`/`background-image` declaration.