| @@ -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. |