[width, height] */ private $dimension_resolver; /** @var string Site host, to tell third-party resources apart. */ private $site_host; /** @var array Script/template/textarea protected during the media passes. */ private $shielded = array(); /** @var array Blocks protected during minification. */ private $protected = array(); /** @var array External origins found in the page. */ private $origins = array(); /** @var string|null tag for the LCP image. */ private $lcp_preload = null; /** @var bool */ private $has_delayed = false; /** @var bool */ private $has_facade = false; /** @var bool */ private $has_lazy_bg = false; /** @var int[] Counters for the debug log. */ private $stats = array( 'deferred' => 0, 'delayed' => 0, 'lazy' => 0, 'lcp' => 0, 'facades' => 0, 'lazy_bg' => 0 ); public function stats() { return $this->stats; } const JS_TYPES = array( '', 'text/javascript', 'application/javascript', 'application/x-javascript', 'text/ecmascript', 'module' ); public function __construct( array $options, $site_host, $dimension_resolver = null ) { $this->o = $options; $this->site_host = strtolower( (string) $site_host ); $this->dimension_resolver = $dimension_resolver; } private function opt( $key ) { return isset( $this->o[ $key ] ) ? $this->o[ $key ] : null; } private function lines( $key ) { $raw = $this->opt( $key ); if ( is_array( $raw ) ) { return $raw; } return array_values( array_filter( array_map( 'trim', preg_split( '/\r\n|\r|\n/', (string) $raw ) ), array( __CLASS__, 'not_empty' ) ) ); } /** * Entry point. */ public static function not_empty( $s ) { return '' !== $s; } public function process( $html ) { if ( ! is_string( $html ) || '' === $html ) { return $html; } // Only complete HTML documents: no AMP, feeds, JSON or fragments. if ( false === stripos( $html, '' ) || false === stripos( $html, ']*\s(amp|⚡)[\s>=]/iu', $html ) ) { return $html; } $steps = array( 'process_scripts', 'process_styles', 'process_images', 'process_backgrounds', 'process_iframes', 'inject_head', 'inject_footer' ); if ( $this->opt( 'minify_html' ) ) { $steps[] = 'minify'; } foreach ( $steps as $step ) { if ( 'process_images' === $step ) { $html = $this->shield( $html ); } if ( 'inject_head' === $step ) { $html = $this->unshield( $html ); } $result = $this->$step( $html ); // If a regex exceeds the PCRE limits (huge pages) the step is skipped. if ( is_string( $result ) && '' !== $result ) { $html = $result; } } // Internal placeholder of CTPS_Webp::wrap_avif (only needed for the LCP preload). if ( false !== strpos( $html, 'data-ctps-avif=' ) ) { $html = preg_replace( '/\sdata-ctps-avif="[^"]*"/', '', $html ); } return $html; } /* --------------------------------------------------------------------- * Attribute helpers * ------------------------------------------------------------------ */ /** * Attributes of the opening tag, one by one (quoted values are * consumed whole: "loading" inside alt="..." is not an attribute). * * @return array[] [ lowercase name, value|null, offset, length ] */ private static function attrs( $tag ) { if ( ! preg_match( '/^<[^\s>\/]+/', $tag, $t ) ) { return array(); } $out = array(); if ( preg_match_all( '/\s+([^\s=>\/"\']+)(?:\s*=\s*(?:"([^"]*)"|\'([^\']*)\'|([^\s>"\']+)))?/', $tag, $all, PREG_SET_ORDER | PREG_OFFSET_CAPTURE, strlen( $t[0] ) ) ) { foreach ( $all as $m ) { $value = null; foreach ( array( 2, 3, 4 ) as $i ) { if ( isset( $m[ $i ] ) && -1 !== $m[ $i ][1] ) { $value = $m[ $i ][0]; break; } } $out[] = array( strtolower( $m[1][0] ), $value, $m[0][1], strlen( $m[0][0] ) ); } } return $out; } public static function get_attr( $tag, $name ) { $name = strtolower( $name ); foreach ( self::attrs( $tag ) as $a ) { if ( $a[0] === $name ) { return null === $a[1] ? '' : $a[1]; } } return null; } public static function has_attr( $tag, $name ) { $name = strtolower( $name ); foreach ( self::attrs( $tag ) as $a ) { if ( $a[0] === $name ) { return true; } } return false; } public static function remove_attr( $tag, $name ) { $name = strtolower( $name ); foreach ( array_reverse( self::attrs( $tag ) ) as $a ) { if ( $a[0] === $name ) { $tag = substr( $tag, 0, $a[2] ) . substr( $tag, $a[2] + $a[3] ); } } return $tag; } public static function set_attr( $tag, $name, $value ) { $tag = self::remove_attr( $tag, $name ); $attr = null === $value ? ' ' . $name : ' ' . $name . '="' . htmlspecialchars( (string) $value, ENT_QUOTES, 'UTF-8', false ) . '"'; // Inserts before the end of the opening tag ( > or /> ). return preg_replace( '/\s*(\/?)>$/', $attr . '$1>', $tag, 1 ); } /** * Replaces script, template and textarea with placeholders: the passes over * images, backgrounds and iframes must not touch HTML written inside JS. */ private function shield( $html ) { $this->shielded = array(); $out = preg_replace_callback( '#<(script|template|textarea)\b[^>]*>.*?#is', function ( $m ) { $key = '%%CTPSSH' . count( $this->shielded ) . '%%'; $this->shielded[ $key ] = $m[0]; return $key; }, $html ); return is_string( $out ) ? $out : $html; } private function unshield( $html ) { return $this->shielded ? strtr( $html, $this->shielded ) : $html; } private function matches_any( $haystack, array $patterns ) { foreach ( $patterns as $p ) { if ( '' !== $p && false !== stripos( $haystack, $p ) ) { return true; } } return false; } private function is_external( $url ) { if ( ! preg_match( '#^(?:https?:)?//([^/:?\#]+)#i', $url, $m ) ) { return false; } return strtolower( $m[1] ) !== $this->site_host; } private function remember_origin( $url ) { if ( preg_match( '#^(?:(https?):)?//([^/?\#]+)#i', $url, $m ) && $this->is_external( $url ) ) { $origin = ( $m[1] ? strtolower( $m[1] ) : 'https' ) . '://' . strtolower( $m[2] ); $this->origins[ $origin ] = isset( $this->origins[ $origin ] ) ? $this->origins[ $origin ] + 1 : 1; } } /* --------------------------------------------------------------------- * JavaScript: defer + delay until interaction * ------------------------------------------------------------------ */ /** * Text matched by exclusion lists and patterns for a tag: current URL, * original URL before minification/localization (data-ctps-orig) and id. */ private function match_subject( $tag, $url ) { $orig = self::get_attr( $tag, 'data-ctps-orig' ); return (string) $url . ' ' . ( null !== $orig ? $orig : '' ) . ' ' . self::get_attr( $tag, 'id' ); } /** * WordPress handle from a script id ("{handle}-js", "{handle}-js-extra", ...). */ private static function script_handle( $id, $inline ) { $id = (string) $id; if ( $inline ) { return preg_match( '/^(.+)-js-(?:extra|before|after|translations)$/', $id, $h ) ? $h[1] : null; } return preg_match( '/^(.+)-js$/', $id, $h ) ? $h[1] : null; } private function process_scripts( $html ) { $defer = (bool) $this->opt( 'defer_js' ); $delay = (bool) $this->opt( 'delay_js' ); $defer_exclude = $this->lines( 'defer_exclude' ); $delay_patterns = $this->lines( 'delay_patterns' ); $delay_never = $this->lines( 'delay_never' ); $seen_deferred = false; if ( ! preg_match_all( '#]*)>(.*?)#is', $html, $all, PREG_SET_ORDER | PREG_OFFSET_CAPTURE ) ) { return $html; } // First pass: decision for each script, in document order. $items = array(); $sync_hdl = array(); // Handles of external scripts that stay synchronous. $last_sync = -1; // Index of the last synchronous external script. foreach ( $all as $i => $mm ) { $m = array( $mm[0][0], $mm[1][0], $mm[2][0] ); $open = ''; $content = $m[2]; $type = strtolower( trim( (string) self::get_attr( $open, 'type' ) ) ); $src = self::get_attr( $open, 'src' ); $item = array( 'start' => $mm[0][1], 'len' => strlen( $m[0] ), 'orig' => $m[0], 'out' => $m[0], 'inline' => false, // Inline script converted to a deferred data URI. ); $is_sync = null !== $src && 'module' !== $type && ! self::has_attr( $open, 'nomodule' ) && ! self::has_attr( $open, 'async' ) && ! self::has_attr( $open, 'defer' ); if ( ! in_array( $type, self::JS_TYPES, true ) ) { $items[] = $item; // JSON-LD, templates, etc. continue; } if ( self::has_attr( $open, 'data-ctps-skip' ) || self::has_attr( $open, 'data-no-optimize' ) || self::has_attr( $open, 'nomodule' ) ) { if ( $is_sync ) { $last_sync = $i; $handle = self::script_handle( self::get_attr( $open, 'id' ), false ); if ( null !== $handle ) { $sync_hdl[ $handle ] = true; } } $items[] = $item; continue; } // 1) Third-party scripts: delayed until the first interaction. // External scripts: the URL is matched. Inline scripts: only against entries that // are domains or calls (e.g. "connect.facebook.net", "fbq("), never against // generic words that would also show up in ordinary configurations. if ( null !== $src ) { $is_delayed = $delay && $delay_patterns && $this->matches_any( $this->match_subject( $open, $src ), $delay_patterns ); } else { $inline_patterns = array_filter( $delay_patterns, function ( $p ) { return false !== strpos( $p, '.' ) || false !== strpos( $p, '(' ); } ); $is_delayed = $delay && $inline_patterns && $this->matches_any( $content, $inline_patterns ); } // Cookie consent, payments and anti-spam: never delayed (compatibility list). if ( $is_delayed && $delay_never && $this->matches_any( $this->match_subject( $open, null !== $src ? $src : $content ), $delay_never ) ) { $is_delayed = false; } if ( $is_delayed ) { $this->stats['delayed']++; $this->has_delayed = true; $new = self::set_attr( $open, 'type', 'ctps/delay' ); $new = self::set_attr( $new, 'data-ctps-type', '' === $type ? 'text/javascript' : $type ); if ( null !== $src ) { $new = self::remove_attr( $new, 'src' ); $new = self::set_attr( $new, 'data-ctps-src', $src ); } $item['out'] = $new . $content . ''; $items[] = $item; continue; } if ( null !== $src ) { $this->remember_origin( $src ); } if ( ! $defer || 'module' === $type ) { $items[] = $item; // Modules are already deferred. continue; } // 2) External scripts: defer (async already does not block). if ( null !== $src ) { if ( self::has_attr( $open, 'async' ) || self::has_attr( $open, 'defer' ) ) { $seen_deferred = $seen_deferred || self::has_attr( $open, 'defer' ); $items[] = $item; continue; } if ( $this->matches_any( $this->match_subject( $open, $src ), $defer_exclude ) ) { // Stays synchronous: its inline data and the inline scripts before it too. $last_sync = $i; $handle = self::script_handle( self::get_attr( $open, 'id' ), false ); if ( null !== $handle ) { $sync_hdl[ $handle ] = true; } $items[] = $item; continue; } $seen_deferred = true; $this->stats['deferred']++; $item['out'] = self::set_attr( $open, 'defer', null ) . $content . ''; $items[] = $item; continue; } // 3) Inline scripts following a deferred script must be deferred // too (data URI + defer) to keep the execution order. if ( $seen_deferred && '' !== trim( $content ) && ! preg_match( '/document\.write|\bimportmap\b/i', $content ) && ! $this->matches_any( $content . ' ' . self::get_attr( $open, 'id' ), $defer_exclude ) ) { // base64 is only used to build a data: URI, so that an inline script can // carry the defer attribute (defer works only with src). Not obfuscation. $new = self::set_attr( $open, 'src', 'data:text/javascript;base64,' . base64_encode( $content ) ); $new = self::set_attr( $new, 'defer', null ); $item['out'] = $new . ''; $item['inline'] = true; $item['handle'] = self::script_handle( self::get_attr( $open, 'id' ), true ); } $items[] = $item; } // Second pass: an inline script stays synchronous when it belongs to a // script that stays synchronous (wp_localize_script / wp_add_inline_script data) // or when a synchronous external script comes after it (it may configure it). $out = ''; $pos = 0; foreach ( $items as $i => $item ) { $piece = $item['out']; if ( $item['inline'] && ( $i < $last_sync || ( null !== $item['handle'] && isset( $sync_hdl[ $item['handle'] ] ) ) ) ) { $piece = $item['orig']; } $out .= substr( $html, $pos, $item['start'] - $pos ) . $piece; $pos = $item['start'] + $item['len']; } return $out . substr( $html, $pos ); } /* --------------------------------------------------------------------- * CSS: Google Fonts with display=swap, font-display, async CSS * ------------------------------------------------------------------ */ private function process_styles( $html ) { $async = (bool) $this->opt( 'async_css' ); $async_exclude = $this->lines( 'async_css_exclude' ); $swap = (bool) $this->opt( 'font_display_swap' ); $html = preg_replace_callback( '#]*>#i', function ( $m ) use ( $async, $async_exclude, $swap ) { $tag = $m[0]; $rel = strtolower( (string) self::get_attr( $tag, 'rel' ) ); $href = self::get_attr( $tag, 'href' ); if ( 'stylesheet' !== $rel || null === $href ) { return $tag; } $this->remember_origin( $href ); if ( $swap && false !== stripos( $href, 'fonts.googleapis.com' ) && false === stripos( $href, 'display=' ) ) { $href = html_entity_decode( $href, ENT_QUOTES | ENT_HTML5 ); $tag = self::set_attr( $tag, 'href', $href . ( false === strpos( $href, '?' ) ? '?' : '&' ) . 'display=swap' ); } if ( ! $async || self::has_attr( $tag, 'data-ctps-skip' ) || self::has_attr( $tag, 'onload' ) ) { return $tag; } if ( $this->matches_any( $this->match_subject( $tag, $href ), $async_exclude ) ) { return $tag; } $media = self::get_attr( $tag, 'media' ); if ( null !== $media && 'all' !== strtolower( $media ) && 'screen' !== strtolower( $media ) ) { return $tag; // print or specific media queries: they do not block anyway. } $async_tag = self::set_attr( $tag, 'media', 'print' ); $async_tag = self::set_attr( $async_tag, 'onload', "this.media='" . ( $media ? $media : 'all' ) . "';this.onload=null" ); return $async_tag . ''; }, $html ); if ( $swap ) { $html = preg_replace_callback( '#(]*>)(.*?)()#is', function ( $m ) { $css = preg_replace_callback( '/@font-face\s*\{([^}]*)\}/i', function ( $f ) { return false === stripos( $f[1], 'font-display' ) ? '@font-face{font-display:swap;' . $f[1] . '}' : $f[0]; }, $m[2] ); return $m[1] . $css . $m[3]; }, $html ); } return $html; } /* --------------------------------------------------------------------- * Images: lazy-load, LCP priority, explicit dimensions, alt * ------------------------------------------------------------------ */ private function looks_like_logo_or_icon( $tag, $width ) { if ( null !== $width && (int) $width > 0 && (int) $width < 150 ) { return true; } $probe = strtolower( self::get_attr( $tag, 'class' ) . ' ' . self::get_attr( $tag, 'src' ) . ' ' . self::get_attr( $tag, 'id' ) ); return (bool) preg_match( '/logo|icon|avatar|gravatar|emoji|badge/', $probe ); } private function process_images( $html ) { $lazy = (bool) $this->opt( 'lazy_load' ); $skip = max( 0, (int) $this->opt( 'lazy_skip' ) ); $preload = (bool) $this->opt( 'lcp_preload' ); $add_dims = (bool) $this->opt( 'add_dimensions' ); $add_alt = (bool) $this->opt( 'missing_alt' ); $count = 0; $lcp_done = false; $body_pos = stripos( $html, ']*\sfetchpriority\s*=\s*["\']?high\b#i', $body ); // Images inside )|]*>#is', function ( $m ) use ( $lazy, $skip, $preload, $add_dims, $add_alt, $declared, &$count, &$lcp_done ) { if ( ! empty( $m[1] ) ) { return $m[0]; } $tag = $m[0]; if ( self::has_attr( $tag, 'data-ctps-skip' ) || self::has_attr( $tag, 'data-no-lazy' ) ) { return $tag; } $src = self::get_attr( $tag, 'src' ); if ( null === $src || 0 === strpos( $src, 'data:' ) ) { return $tag; } $count++; $this->remember_origin( $src ); $width = self::get_attr( $tag, 'width' ); $height = self::get_attr( $tag, 'height' ); // Explicit dimensions against CLS. if ( $add_dims && ( null === $width || null === $height ) && is_callable( $this->dimension_resolver ) ) { $dims = call_user_func( $this->dimension_resolver, $src ); if ( is_array( $dims ) && ! empty( $dims[0] ) && ! empty( $dims[1] ) ) { if ( null === $width && null === $height ) { $width = (int) $dims[0]; $height = (int) $dims[1]; } elseif ( null === $height && is_numeric( $width ) ) { $height = (int) round( $width * $dims[1] / $dims[0] ); } elseif ( null === $width && is_numeric( $height ) ) { $width = (int) round( $height * $dims[0] / $dims[1] ); } $tag = self::set_attr( $tag, 'width', $width ); $tag = self::set_attr( $tag, 'height', $height ); } } if ( $add_alt && ! self::has_attr( $tag, 'alt' ) ) { $tag = self::set_attr( $tag, 'alt', '' ); } // LCP candidate: the image with fetchpriority="high", otherwise the // first "content" image of the page. $is_high = 'high' === strtolower( trim( (string) self::get_attr( $tag, 'fetchpriority' ) ) ); $is_lcp = false; if ( $preload && ! $lcp_done && ( $declared ? $is_high : ( $count <= max( 3, $skip + 1 ) && ! $this->looks_like_logo_or_icon( $tag, $width ) ) ) ) { $is_lcp = true; $this->stats['lcp']++; $lcp_done = true; $tag = self::remove_attr( $tag, 'loading' ); $tag = self::set_attr( $tag, 'fetchpriority', 'high' ); $tag = self::remove_attr( $tag, 'decoding' ); $link = ': the AVIF is preloaded (browsers that do not // support it ignore the preload thanks to type). $link = ' at the * top of only made it compete with render-blocking CSS: measured * FCP/LCP +90-110 ms on throttled mobile. Opt back in with the filter. */ if ( apply_filters( 'ctps_lcp_preload_link', false, $tag ) ) { $this->lcp_preload = $link . ' fetchpriority="high">'; } } if ( $is_lcp || ! $lazy ) { return $tag; } if ( $is_high ) { // Never lazy-load a high-priority (LCP) image. if ( 'lazy' === strtolower( (string) self::get_attr( $tag, 'loading' ) ) ) { $tag = self::remove_attr( $tag, 'loading' ); } return $tag; } if ( $count <= $skip ) { // Above the fold: no lazy loading (it would hurt LCP). if ( 'lazy' === strtolower( (string) self::get_attr( $tag, 'loading' ) ) ) { $tag = self::remove_attr( $tag, 'loading' ); } return $tag; } if ( ! self::has_attr( $tag, 'loading' ) ) { $tag = self::set_attr( $tag, 'loading', 'lazy' ); $this->stats['lazy']++; } if ( ! self::has_attr( $tag, 'decoding' ) ) { $tag = self::set_attr( $tag, 'decoding', 'async' ); } return $tag; }, $body ); return is_string( $body ) ? $head . $body : $html; } /* --------------------------------------------------------------------- * Iframes: lazy-load and YouTube facade * ------------------------------------------------------------------ */ /** * Background images in inline styles (page builder sections): * loaded only when the element gets close to the viewport. */ private function process_backgrounds( $html ) { if ( ! $this->opt( 'lazy_bg' ) ) { return $html; } $skip = max( 0, (int) $this->opt( 'lazy_skip' ) ); $count = 0; $body_pos = stripos( $html, ']*>.*?)|<([a-z][a-z0-9-]*)\b[^>]*\sstyle\s*=\s*("[^"]*"|\'[^\']*\')[^>]*>#is', function ( $m ) use ( $skip, &$count ) { if ( ! empty( $m[1] ) ) { return $m[0]; } $tag = $m[0]; $style = html_entity_decode( substr( $m[3], 1, -1 ), ENT_QUOTES ); if ( ! preg_match( '/background(?:-image)?\s*:[^;]*url\(\s*([\'"]?)([^\'")]+)\1\s*\)/i', $style, $u ) ) { return $tag; } if ( 0 === strpos( $u[2], 'data:' ) || self::has_attr( $tag, 'data-ctps-skip' ) || self::has_attr( $tag, 'data-ctps-bg' ) ) { return $tag; } $count++; if ( $count <= $skip ) { return $tag; } $this->has_lazy_bg = true; $this->stats['lazy_bg']++; $new_style = preg_replace( '/url\(\s*([\'"]?)' . preg_quote( $u[2], '/' ) . '\1\s*\)/i', 'none', $style, 1 ); $tag = self::set_attr( $tag, 'style', $new_style ); $tag = self::set_attr( $tag, 'data-ctps-bg', $u[2] ); $class = self::get_attr( $tag, 'class' ); return self::set_attr( $tag, 'class', trim( $class . ' ctps-lazy-bg' ) ); }, $body ); return is_string( $body ) ? $head . $body : $html; } private function process_iframes( $html ) { $lazy = (bool) $this->opt( 'lazy_iframes' ); $facade = (bool) $this->opt( 'youtube_facade' ); if ( ! $lazy && ! $facade ) { return $html; } return preg_replace_callback( '#(]*>.*?)|(]*>)(.*?)#is', function ( $m ) use ( $lazy, $facade ) { if ( ! empty( $m[1] ) ) { return $m[0]; } $tag = $m[2]; $src = (string) self::get_attr( $tag, 'src' ); // Iframes already lazy-loaded by another plugin (LiteSpeed Cache, // WP Rocket, Perfmatters...) keep the real address in a data // attribute and a placeholder in src (3.7.26). $real = ''; if ( $facade && ( '' === $src || preg_match( '#^(?:about:blank|data:|javascript:)#i', $src ) ) ) { foreach ( array( 'data-src', 'data-lazy-src', 'data-litespeed-src', 'data-rocket-src' ) as $a ) { $v = (string) self::get_attr( $tag, $a ); if ( '' !== $v && preg_match( '#(?:youtube\.com|youtube-nocookie\.com)/embed/#i', $v ) ) { $real = $v; break; } } } if ( '' !== $real ) { $src = $real; } if ( '' === $src || self::has_attr( $tag, 'data-ctps-skip' ) ) { return $m[0]; } // Only a valid 11-character video ID is used to build the thumbnail URL. if ( $facade && preg_match( '#(?:youtube\.com|youtube-nocookie\.com)/embed/([A-Za-z0-9_-]{11})(?![A-Za-z0-9_-])#i', $src, $yt ) && preg_match( '/^[A-Za-z0-9_-]{11}$/', $yt[1] ) ) { $this->has_facade = true; $this->stats['facades']++; $id = $yt[1]; $w = (int) self::get_attr( $tag, 'width' ); $h = (int) self::get_attr( $tag, 'height' ); $ratio = ( $w > 0 && $h > 0 ) ? $w . '/' . $h : '16/9'; $title = self::get_attr( $tag, 'title' ); $title = $title ? $title : __( 'YouTube video', 'celero-adaptive-speed' ); $embed = html_entity_decode( $src, ENT_QUOTES | ENT_HTML5 ); $embed .= ( false === strpos( $embed, '?' ) ? '?' : '&' ) . 'autoplay=1'; $esc = function ( $v ) { return htmlspecialchars( $v, ENT_QUOTES, 'UTF-8' ); }; return '
' . '' . $esc( $title ) . '' /* translators: %s: video title */ . '
'; } if ( $lazy && ! self::has_attr( $tag, 'loading' ) ) { $tag = self::set_attr( $tag, 'loading', 'lazy' ); } return $tag . $m[3] . ''; }, $html ); } /* --------------------------------------------------------------------- * : preconnect, preload, critical CSS * ------------------------------------------------------------------ */ private function inject_head( $html ) { $out = ''; if ( $this->opt( 'auto_preconnect' ) || $this->lines( 'preconnect_extra' ) ) { $origins = array(); foreach ( $this->lines( 'preconnect_extra' ) as $o ) { $origins[ rtrim( $o, '/' ) ] = PHP_INT_MAX; } if ( $this->opt( 'auto_preconnect' ) ) { foreach ( $this->origins as $o => $n ) { $origins[ $o ] = isset( $origins[ $o ] ) ? $origins[ $o ] : $n; } if ( isset( $origins['https://fonts.googleapis.com'] ) ) { $origins['https://fonts.gstatic.com'] = PHP_INT_MAX - 1; } } arsort( $origins ); $existing = $this->existing_preconnects( $html ); // Lighthouse advises against more than ~4 preconnects. foreach ( array_slice( array_keys( $origins ), 0, 4 ) as $origin ) { $norm = self::normalize_origin( $origin ); if ( '' === $norm || isset( $existing[ $norm ] ) ) { continue; } $existing[ $norm ] = true; $cross = ( false !== strpos( $origin, 'fonts.gstatic.com' ) ) ? ' crossorigin' : ''; $out .= ''; } } if ( $this->lcp_preload ) { $out .= $this->lcp_preload; } foreach ( $this->lines( 'preload_extra' ) as $url ) { $path = strtolower( (string) wp_parse_url( $url, PHP_URL_PATH ) ); $ext = pathinfo( $path, PATHINFO_EXTENSION ); $map = array( 'woff2' => 'font', 'woff' => 'font', 'ttf' => 'font', 'otf' => 'font', 'css' => 'style', 'js' => 'script', 'jpg' => 'image', 'jpeg' => 'image', 'png' => 'image', 'webp' => 'image', 'avif' => 'image', 'svg' => 'image', 'gif' => 'image', ); if ( ! isset( $map[ $ext ] ) ) { continue; } $cross = 'font' === $map[ $ext ] ? ' type="font/' . $ext . '" crossorigin' : ''; $out .= ''; } // Critical CSS: enqueued as an inline style (CTPS_Front_Assets). if ( '' === $out ) { return $html; } // Right after if present, otherwise after . if ( preg_match( '#]*>#i', $html, $m, PREG_OFFSET_CAPTURE ) && $m[0][1] < stripos( $html, '' ) ) { $pos = $m[0][1] + strlen( $m[0][0] ); } elseif ( preg_match( '#]*>#i', $html, $m, PREG_OFFSET_CAPTURE ) ) { $pos = $m[0][1] + strlen( $m[0][0] ); } else { return $html; } return substr( $html, 0, $pos ) . $out . substr( $html, $pos ); } /** * Origins of the already in the page, whatever the * quoting style or the order of the attributes. * * @return array normalized origin => true */ private function existing_preconnects( $html ) { $found = array(); if ( ! preg_match_all( '#]*>#i', $html, $links ) ) { return $found; } foreach ( $links[0] as $tag ) { $rel = preg_split( '/\s+/', strtolower( trim( (string) self::get_attr( $tag, 'rel' ) ) ) ); if ( ! in_array( 'preconnect', $rel, true ) ) { continue; } $norm = self::normalize_origin( (string) self::get_attr( $tag, 'href' ) ); if ( '' !== $norm ) { $found[ $norm ] = true; } } return $found; } /** * "scheme://host[:port]" in lowercase ("//host" is treated as https). */ private static function normalize_origin( $url ) { $url = trim( html_entity_decode( (string) $url, ENT_QUOTES | ENT_HTML5 ) ); if ( 0 === strpos( $url, '//' ) ) { $url = 'https:' . $url; } if ( ! preg_match( '#^(https?)://([^/?\#\s]+)#i', $url, $m ) ) { return ''; } return strtolower( $m[1] ) . '://' . strtolower( $m[2] ); } /* --------------------------------------------------------------------- * End of : delayed scripts loader and YouTube facade * ------------------------------------------------------------------ */ private function inject_footer( $html ) { $out = ''; // Loaders of delayed scripts and lazy backgrounds: enqueued by // CTPS_Front_Assets. Only when the theme does not call wp_footer() // (no enqueued footer scripts) they are added here, so delayed scripts // never stay unloaded. if ( $this->has_delayed && false === strpos( $html, 'id="ctps-delay-js-after"' ) ) { $out .= ''; } if ( $this->has_lazy_bg && false === strpos( $html, 'id="ctps-lazy-bg-js-after"' ) ) { $out .= ''; } // Styles and click script of the YouTube facade: enqueued (CTPS_Front_Assets). if ( '' === $out ) { return $html; } $pos = strripos( $html, '' ); if ( false === $pos ) { return $html . $out; } return substr( $html, 0, $pos ) . $out . substr( $html, $pos ); } /* --------------------------------------------------------------------- * Conservative HTML minification * ------------------------------------------------------------------ */ private function minify( $html ) { $this->protected = array(); // Protects blocks where whitespace matters. $html = preg_replace_callback( '#<(pre|textarea|script|style|code)\b[^>]*>.*?#is', function ( $m ) { $key = '%%CTPS' . count( $this->protected ) . '%%'; $this->protected[ $key ] = $m[0]; return $key; }, $html ); if ( $this->opt( 'remove_html_comments' ) ) { // Keeps IE conditional comments and those marked to be preserved. $html = preg_replace( '//s', '', $html ); } $html = preg_replace( '/[ \t\r\n\f]+/', ' ', $html ); $html = preg_replace( '/>\s+ <', $html ); $html = preg_replace( '/\s*(<\/?(?:html|head|body|meta|link|title|base|!doctype)\b[^>]*>)\s*/i', '$1', $html ); return strtr( $html, $this->protected ); } }