[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[^>]*>.*?\1\s*>#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 = ''; $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 )|<([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( '#(
'
/* translators: %s: video title */
. '