]*\bsrcset=[^>]*>/i', array( $this, 'source_tag' ), $html );
$html = preg_replace_callback( '/]*\bdata-thumb=[^>]*>/i', array( $this, 'gallery_tag' ), $html );
if ( $this->lcp_preload && false === stripos( $html, 'rel="preload" as="image"' ) ) {
$html = self::after_tag( $html, '/]*>/i', $this->lcp_preload );
}
return $html;
}
/**
* WooCommerce product gallery: thumbnails and the zoom image are built by its JavaScript from
* data-thumb / data-thumb-srcset / data-large_image on each slide. Sending those through the
* edge too keeps them in the same URL space as the main image (the browser reuses the large file
* it already has for a thumbnail instead of fetching a too-small one) and optimises the zoom image.
*/
private function gallery_tag( $m ) {
$tag = $m[0];
$attrs = $this->attrs( $tag );
if ( self::opted_out( $attrs ) || ZeroConfig_Compat::matches( $tag, (array) $this->rules['img_exclude'] ) ) {
return $tag;
}
if ( ! empty( $attrs['data-thumb'] ) && $this->is_local_image( $attrs['data-thumb'] ) ) {
$w = self::known_width( $attrs['data-thumb'], '' );
$tag = $this->set_attr( $tag, 'data-thumb', $this->img_url( $attrs['data-thumb'], $w ? $w : self::MAX_UNKNOWN_WIDTH ) );
}
if ( ! empty( $attrs['data-thumb-srcset'] ) ) {
$tag = $this->set_attr( $tag, 'data-thumb-srcset', $this->rewrite_srcset( $attrs['data-thumb-srcset'] ) );
}
return $tag;
}
private function img_tag( $m ) {
$tag = $m[0];
$attrs = $this->attrs( $tag );
$src = isset( $attrs['src'] ) ? $attrs['src'] : '';
$this->img_index++;
if ( '' === $src || ! $this->is_local_image( $src ) || ZeroConfig_Compat::matches( $tag, (array) $this->rules['img_exclude'] ) || self::opted_out( $attrs ) ) {
return $tag;
}
// Only a plain pixel count is a width. "100%", "auto" or "50vw" would otherwise become w_100 / w_50 and a blurry image.
$width = isset( $attrs['width'] ) && ctype_digit( trim( $attrs['width'] ) ) ? (int) trim( $attrs['width'] ) : 0;
if ( ! $width ) {
// No width attribute (cover blocks, galleries, page builders). Without one the edge would
// only convert the format and send a 4000 px camera original to a phone.
$width = self::known_width( $src, isset( $attrs['class'] ) ? $attrs['class'] : '' );
}
$new = $this->img_url( $src, $width ? $width : self::MAX_UNKNOWN_WIDTH );
$tag = $this->set_attr( $tag, 'src', $new );
// WooCommerce zoom / lightbox image (and lazy loaders' data-src): same edge treatment.
$large_w = isset( $attrs['data-large_image_width'] ) && ctype_digit( (string) $attrs['data-large_image_width'] ) ? (int) $attrs['data-large_image_width'] : 0;
foreach ( array( 'data-large_image', 'data-src' ) as $a ) {
if ( ! empty( $attrs[ $a ] ) && $this->is_local_image( $attrs[ $a ] ) ) {
$w = ( isset( $attrs['data-large_image'] ) && $attrs[ $a ] === $attrs['data-large_image'] && $large_w ) ? $large_w : self::known_width( $attrs[ $a ], '' );
$tag = $this->set_attr( $tag, $a, $this->img_url( $attrs[ $a ], $w ? $w : self::MAX_UNKNOWN_WIDTH ) );
}
}
if ( isset( $attrs['srcset'] ) ) {
$tag = $this->set_attr( $tag, 'srcset', $this->rewrite_srcset( $attrs['srcset'] ) );
} elseif ( $width >= 400 ) {
$set = array();
foreach ( array( 320, 480, 640, 960, 1280, 1600, 1920 ) as $w ) {
if ( $w < $width ) {
$set[] = $this->img_url( $src, $w ) . ' ' . $w . 'w';
}
}
$set[] = $new . ' ' . $width . 'w';
$tag = $this->set_attr( $tag, 'srcset', implode( ', ', $set ) );
if ( ! isset( $attrs['sizes'] ) ) {
$tag = $this->set_attr( $tag, 'sizes', '(max-width: ' . $width . 'px) 100vw, ' . $width . 'px' );
}
}
// LCP-aware lazy loading: the first two images are likely above the fold.
if ( $this->img_index <= 2 ) {
if ( ! isset( $attrs['fetchpriority'] ) ) {
$tag = $this->set_attr( $tag, 'fetchpriority', 'high' );
}
$tag = preg_replace( '/\sloading=("|\')lazy\1/i', '', $tag );
// The first large image is the likely LCP: start fetching it from , before CSS and
// scripts. Otherwise a gallery script can run first and Chrome (and Lighthouse) count the
// image as painted only after it. Small images (logos, icons) are not worth a preload.
if ( null === $this->lcp_preload && ( $width >= 300 || ( ! $width && $this->img_index === 1 ) ) ) {
$t = $this->attrs( $tag );
$this->lcp_preload = '';
}
} elseif ( ! isset( $attrs['loading'] ) && 'high' !== strtolower( (string) ( $attrs['fetchpriority'] ?? '' ) ) ) {
// Not when the theme marked it fetchpriority="high": lazy and high together contradict
// each other, and Chrome warns about it.
$tag = $this->set_attr( $tag, 'loading', 'lazy' );
}
if ( ! isset( $attrs['decoding'] ) ) {
$tag = $this->set_attr( $tag, 'decoding', 'async' );
}
$this->stats['img']++;
return $tag;
}
/** Width used when nothing tells us the real one: large enough for any layout, small enough to matter. */
const MAX_UNKNOWN_WIDTH = 1920;
/**
* The pixel width of a local image when the tag does not say: the "-800x600" size suffix
* WordPress puts in resized file names, or the attachment's metadata (class "wp-image-123").
*/
private static function known_width( $src, $class ) {
$path = (string) wp_parse_url( $src, PHP_URL_PATH );
if ( preg_match( '/-(\d{2,5})x\d{2,5}\.(?:jpe?g|png|gif|webp|avif)$/i', $path, $m ) ) {
return (int) $m[1];
}
if ( preg_match( '/(?:^|\s)wp-image-(\d+)(?:\s|$)/', $class, $m ) ) {
$meta = wp_get_attachment_metadata( (int) $m[1] );
// The tag may show the upload itself while WordPress keeps a "-scaled" copy (originals over
// 2560 px) as the attachment file. Either way the scaled width is the most we ever serve.
$names = is_array( $meta ) ? array_filter( array( basename( (string) ( $meta['file'] ?? '' ) ), (string) ( $meta['original_image'] ?? '' ) ) ) : array();
if ( ! empty( $meta['width'] ) && in_array( basename( $path ), $names, true ) ) {
return (int) $meta['width'];
}
}
return 0;
}
private function source_tag( $m ) {
$attrs = $this->attrs( $m[0] );
if ( empty( $attrs['srcset'] ) || ( isset( $attrs['type'] ) && 0 !== strpos( $attrs['type'], 'image/' ) && '' !== $attrs['type'] ) ) {
return $m[0];
}
$new = $this->rewrite_srcset( $attrs['srcset'] );
if ( $new === $attrs['srcset'] ) {
return $m[0];
}
return $this->set_attr( $m[0], 'srcset', $new );
}
private function rewrite_srcset( $srcset ) {
$out = array();
foreach ( explode( ',', $srcset ) as $cand ) {
$cand = trim( $cand );
if ( '' === $cand ) {
continue;
}
$parts = preg_split( '/\s+/', $cand );
$url = $parts[0];
$desc = isset( $parts[1] ) ? $parts[1] : '';
$w = preg_match( '/^(\d+)w$/', $desc, $wm ) ? (int) $wm[1] : 0;
$out[] = ( $this->is_local_image( $url ) ? $this->img_url( $url, $w ) : $url ) . ( $desc ? ' ' . $desc : '' );
}
return implode( ', ', $out );
}
private function is_local_image( $url ) {
if ( 0 === strpos( $url, 'data:' ) || 0 === strpos( $url, '#' ) ) {
return false;
}
$path = (string) wp_parse_url( $url, PHP_URL_PATH );
if ( ! preg_match( '/\.(jpe?g|png|webp|avif)$/i', $path ) ) {
return false;
}
return $this->is_local( $url );
}
private function is_local( $url ) {
if ( 0 === strpos( $url, '//' ) ) {
$url = 'https:' . $url;
}
if ( 0 === strpos( $url, '/' ) ) {
return true;
}
$host = strtolower( (string) wp_parse_url( $url, PHP_URL_HOST ) );
return '' !== $host && ( $host === $this->home_host || 'www.' . $host === $this->home_host || $host === 'www.' . $this->home_host );
}
/**
* /img/{site}/{opts}/{base64url(path?query)}/{original-filename}
* The filename segment is ignored by the edge; it keeps the URL readable
* for humans, logs and Google Images. The original file on the customer's
* server is never touched or renamed.
*/
private function img_url( $src, $width ) {
$rel = $this->relative( $src );
$opts = 'w_' . max( 0, (int) $width ) . ',f_auto';
$ver = $this->version( $src );
if ( $ver ) {
$opts .= ',v_' . $ver; // A replaced file gets a new address, so the year-long CDN copy is never stale.
}
$name = rawurlencode( basename( (string) wp_parse_url( $src, PHP_URL_PATH ) ) );
return $this->cdn . '/img/' . $this->site_id . '/' . $opts . '/' . $this->b64( $rel ) . ( $name ? '/' . $name : '' );
}
/* ---------- stylesheets ---------- */
/** True only for rel="stylesheet" (token list), not for text that merely mentions it, and not alternate styles. */
private static function is_stylesheet( array $attrs ) {
if ( empty( $attrs['rel'] ) ) {
return false;
}
$rel = preg_split( '/\s+/', strtolower( trim( $attrs['rel'] ) ) );
return in_array( 'stylesheet', $rel, true ) && ! in_array( 'alternate', $rel, true );
}
private function styles( $html ) {
$fonts = false;
$html = preg_replace_callback(
'/]*>/i',
function ( $m ) use ( &$fonts ) {
$attrs = $this->attrs( $m[0] );
if ( ! self::is_stylesheet( $attrs ) || empty( $attrs['href'] ) || self::opted_out( $attrs ) ) {
return $m[0];
}
// Google Fonts: ensure display=swap (text visible before the font arrives) and a preconnect.
if ( false !== stripos( $attrs['href'], 'fonts.googleapis.com' ) ) {
$fonts = true;
if ( false === stripos( $attrs['href'], 'display=' ) ) {
return $this->set_attr( $m[0], 'href', $attrs['href'] . ( false === strpos( $attrs['href'], '?' ) ? '?' : '&' ) . 'display=swap' );
}
return $m[0];
}
if ( ! $this->is_local( $attrs['href'] ) || ! preg_match( '/\.css(\?|$)/i', $attrs['href'] ) ) {
return $m[0];
}
if ( ZeroConfig_Compat::matches( $attrs['href'], (array) $this->rules['css_exclude'] ) ) {
return $m[0];
}
$this->stats['css']++;
return $this->set_attr( $m[0], 'href', $this->asset_url( 'css', $attrs['href'] ) );
},
$html
);
if ( $fonts && false === stripos( $html, 'href="https://fonts.gstatic.com"' ) ) {
$html = preg_replace( '/]*>/i', '$0', $html, 1 );
}
return $html;
}
/* ---------- scripts ---------- */
private function scripts( $html, $minify, $delay ) {
$html = preg_replace_callback(
'/#is',
function ( $m ) use ( $allow, $deny ) {
$attrs = $this->attrs( '';
},
$html
);
}
if ( $this->stats['delay'] > 0 ) {
// Runs the delayed scripts in document order on first interaction (or after 5 s). External
// ones keep their order (async=false); inline ones run as soon as they are put back.
$loader = wp_get_inline_script_tag(
implode(
"\n",
array(
'( function () {',
' var started = false;',
' var events = [ "mousemove", "keydown", "touchstart", "wheel", "click", "scroll" ];',
' function runDelayedScripts() {',
' if ( started ) { return; }',
' started = true;',
' events.forEach( function ( name ) { removeEventListener( name, runDelayedScripts, { passive: true } ); } );',
' document.querySelectorAll( \'script[type="zc/delay"]\' ).forEach( function ( held ) {',
' var script = document.createElement( "script" );',
' for ( var i = 0; i < held.attributes.length; i++ ) {',
' var attr = held.attributes[ i ];',
' if ( "type" !== attr.name && "data-zc-src" !== attr.name ) { script.setAttribute( attr.name, attr.value ); }',
' }',
' var src = held.getAttribute( "data-zc-src" );',
' if ( src ) {',
' script.async = false; // external scripts keep document order',
' script.src = src;',
' } else {',
' script.text = held.text;',
' }',
' held.parentNode.replaceChild( script, held );',
' } );',
' }',
' events.forEach( function ( name ) { addEventListener( name, runDelayedScripts, { passive: true } ); } );',
' setTimeout( runDelayedScripts, 5000 );',
'} )();',
)
),
array( 'data-zc-skip' => true )
);
$html = self::before_tag( $html, '', $loader );
}
return $html;
}
/* ---------- critical CSS ---------- */
public static function template_key() {
if ( is_front_page() ) {
return 'front';
}
if ( is_home() ) {
return 'home';
}
if ( function_exists( 'is_product' ) && is_product() ) {
return 'product';
}
if ( function_exists( 'is_shop' ) && ( is_shop() || is_product_category() ) ) {
return 'shop';
}
if ( is_singular( 'post' ) ) {
return 'single';
}
if ( is_page() ) {
return 'page';
}
if ( is_archive() ) {
return 'archive';
}
return 'other';
}
/** This page's critical CSS, or '' when there is none or this address is on the skip list. */
private static function critical_css( array $rules ) {
$all = (array) ZeroConfig_Options::get( 'critical_css' );
$key = self::template_key();
$css = isset( $all[ $key ] ) ? (string) $all[ $key ] : ( isset( $all['front'] ) && 'home' === $key ? $all['front'] : '' );
$uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
return '' !== $css && ! ZeroConfig_Compat::matches( $uri, (array) $rules['critical_skip'] ) ? $css : '';
}
/**
* wp_enqueue_scripts: the critical CSS goes out as an enqueued inline style. critical() below then
* only moves that tag in front of the first stylesheet it defers, or drops it when it defers none.
*/
public static function enqueue() {
// Only when this page goes through end_buffer (engine on, page not skipped).
if ( ! has_filter( 'wp_template_enhancement_output_buffer', array( ZeroConfig_Engine::instance(), 'end_buffer' ) ) || self::should_skip() ) {
return;
}
$rewriter = new self();
$force = isset( $_GET['zc_test'] ) ? sanitize_text_field( wp_unslash( $_GET['zc_test'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- selects which features render, no state change
$css = $rewriter->on( 'critical', $force ) ? self::critical_css( $rewriter->rules ) : '';
if ( '' !== $css ) {
wp_register_style( 'zeroconfig-critical', false, array(), ZEROCONFIG_VERSION );
wp_enqueue_style( 'zeroconfig-critical' );
wp_add_inline_style( 'zeroconfig-critical', $css );
}
}
private function critical( $html ) {
$tag_re = '#\s*#is';
if ( ! preg_match( $tag_re, $html, $found ) ) {
return $html; // nothing enqueued for this page (or the theme has no wp_head)
}
$html = str_replace( $found[0], '', $html );
$links = array();
$html = preg_replace_callback(
'/]*>/i',
function ( $m ) use ( &$links ) {
$attrs = $this->attrs( $m[0] );
// Only real stylesheets. Leave alone: preload/loadCSS patterns (they already have onload), opted-out tags,
// and print/none stylesheets that never apply to the first paint.
if ( ! self::is_stylesheet( $attrs ) || isset( $attrs['onload'] ) || self::opted_out( $attrs ) ) {
return $m[0];
}
$media = isset( $attrs['media'] ) ? trim( $attrs['media'] ) : '';
if ( in_array( strtolower( $media ), array( 'print', 'none' ), true ) ) {
return $m[0];
}
$links[] = $m[0];
$tag = preg_replace( '/\smedia\s*=\s*("[^"]*"|\'[^\']*\'|[^\s>]+)/i', '', $m[0] );
$tag = $this->set_attr( $tag, 'media', 'print' );
if ( '' !== $media && 'all' !== strtolower( $media ) ) {
// Keep e.g. "(max-width: 768px)": restoring plain "all" would apply mobile-only CSS on desktop.
$tag = $this->set_attr( $tag, 'data-zc-media', $media );
}
$tag = $this->set_attr( $tag, 'onload', "this.media=this.getAttribute('data-zc-media')||'all';this.onload=null" );
// Mark the first deferred sheet: the critical