on interaction).
add_filter( 'script_loader_tag', array( Minify_Filters::class, 'delay_script_tag' ), 30, 3 );
add_action( 'wp_footer', array( Minify_Filters::class, 'print_delay_bootstrap' ), 1000 );
}
if ( ! empty( $opts['async_css'] ) ) {
add_filter( 'style_loader_tag', array( Minify_Filters::class, 'async_style_tag' ), 20, 2 );
}
// Phase 4.1b — combine engine. Hook late so every plugin /
// theme has finished enqueueing by the time we walk the queue.
// Priority 999 mirrors the WP-Optimize / Rocket convention.
if ( ! empty( $opts['combine_css'] ) ) {
add_action( 'wp_enqueue_scripts', array( Asset_Combiner::class, 'combine_styles' ), 999 );
}
if ( ! empty( $opts['combine_js'] ) ) {
add_action( 'wp_enqueue_scripts', array( Asset_Combiner::class, 'combine_scripts' ), 999 );
}
}
public static function minify_html( $html ) {
$debug_skip = defined( 'WP_DEBUG' ) && WP_DEBUG;
if ( apply_filters( 'xspeed_skip_minify', $debug_skip ) ) {
return $html;
}
$placeholders = array();
$pattern = '#<(pre|textarea|script|style)\b[^>]*>.*?\1>#is';
$html = preg_replace_callback(
$pattern,
function ( $m ) use ( &$placeholders ) {
$key = '__XSPEED_PH_' . count( $placeholders ) . '__';
$placeholders[ $key ] = $m[0];
return $key;
},
$html
);
$html = preg_replace( '//s', '', $html );
$html = preg_replace( '/\s+/', ' ', $html );
$html = preg_replace( '/>\s+', '><', $html );
$html = trim( $html );
foreach ( $placeholders as $key => $original ) {
$html = str_replace( $key, $original, $html );
}
return $html;
}
public static function rewrite_style( $src, $handle ) {
unset( $handle );
return self::rewrite_asset( $src, 'css' );
}
public static function rewrite_script( $src, $handle ) {
unset( $handle );
return self::rewrite_asset( $src, 'js' );
}
/**
* Replace a local CSS/JS URL with a cached, minified equivalent.
*
* @param string $src Original asset URL.
* @param string $type 'css' or 'js'.
* @return string Possibly rewritten URL.
*/
private static function rewrite_asset( $src, $type ) {
if ( ! is_string( $src ) || '' === $src ) {
return $src;
}
// Skip already-minified files.
if ( false !== strpos( $src, '.min.' ) ) {
return $src;
}
// Resolve to a local path; bail if external or unresolvable.
$path = self::url_to_path( $src );
if ( ! $path || ! is_readable( $path ) ) {
return $src;
}
// Build a cache filename keyed on path + mtime so edits invalidate.
$mtime = filemtime( $path );
$key = md5( $path . '|' . $mtime );
$cache = self::cache_path( $key, $type );
if ( ! file_exists( $cache ) ) {
$ok = self::minify_file( $path, $cache, $type );
if ( ! $ok ) {
return $src;
}
}
// Return a URL to the cached file. Built from known constants — never
// from str_replace on a filesystem path (which would assume the FS
// layout mirrors the URL layout).
return self::min_url() . '/' . $key . '.' . $type;
}
private static function minify_file( $source_path, $target_path, $type ) {
if ( ! class_exists( '\\MatthiasMullie\\Minify\\CSS' ) ) {
return false;
}
// Path-traversal guard: refuse to write anywhere outside our cache
// dir, even if a malicious filter ever produced a poisoned key.
$cache_root = self::min_dir();
self::ensure_dir( $cache_root );
$real_root = realpath( $cache_root );
$real_dir = realpath( dirname( $target_path ) );
if ( ! $real_root || ! $real_dir || 0 !== strpos( $real_dir, $real_root ) ) {
return false;
}
try {
$minifier = ( 'css' === $type )
? new \MatthiasMullie\Minify\CSS( $source_path )
: new \MatthiasMullie\Minify\JS( $source_path );
$minified = $minifier->minify();
// Sanity check: paren/brace/bracket/backtick balance must be preserved.
// matthiasmullie/minify can silently truncate mid-template-literal on
// complex modern JS — bail rather than ship a broken file.
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- WP_Filesystem requires admin context; minification runs on frontend page renders. Source already validated as readable on line 121.
$source = file_get_contents( $source_path );
if ( false === $source || ! self::balanced( $source, $minified ) ) {
return false;
}
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- WP_Filesystem requires admin context; minification runs on frontend page renders.
$bytes = file_put_contents( $target_path, $minified );
return false !== $bytes && file_exists( $target_path );
} catch ( \Throwable $e ) {
return false;
}
}
/**
* Cheap structural sanity check between source + minified bodies.
*
* Counts paired-delimiter tokens (parens, braces, brackets, backticks)
* in each and bails when the counts disagree — matthiasmullie/minify
* has been observed to silently truncate inside template literals on
* complex modern JS (see commit history), shipping a body that LOOKS
* minified but is structurally broken and crashes the page at parse.
*
* Backticks are paired (open + close = same token), so the count
* itself must match exactly. Strings inside the source can contain
* literal `{` / `}` / `[` / `]` that throw off the count by the same
* amount in both bodies (since they survive minification as-is), so
* the equality check is robust to that noise.
*/
private static function balanced( string $source, string $minified ): bool {
$pairs = array( '(', ')', '{', '}', '[', ']', '`' );
foreach ( $pairs as $token ) {
if ( substr_count( $source, $token ) !== substr_count( $minified, $token ) ) {
return false;
}
}
return true;
}
/**
* Resolve a local asset URL to a filesystem path using a strict allowlist
* of "URL prefix → filesystem prefix" pairs registered with WordPress.
*
* We never assume `site_url()` maps to `ABSPATH` (the WordPress root can
* live above the document root in Bedrock-style installs, behind a proxy,
* or on multisite with mapped domains). Each branch resolves through a
* known WP API (plugins, themes, content, includes) and validates that
* `realpath()` of the result still lives under the expected base — so a
* crafted `..`-laden URL cannot escape into the filesystem.
*
* @param string $url Asset URL (may be protocol-relative or absolute).
* @return string|false Absolute filesystem path on success, false otherwise.
*/
private static function url_to_path( $url ) {
if ( ! is_string( $url ) || '' === $url ) {
return false;
}
// Drop query string + fragment.
$clean = strtok( $url, '?#' );
// Normalise protocol-relative + scheme variants of the host so we
// match regardless of whether the asset URL came in over http/https.
$site_host = wp_parse_url( home_url(), PHP_URL_HOST );
if ( 0 === strpos( $clean, '//' ) ) {
$clean = 'https:' . $clean;
}
if ( $site_host ) {
$asset_host = wp_parse_url( $clean, PHP_URL_HOST );
if ( $asset_host && $asset_host !== $site_host ) {
return false; // External asset — never touch.
}
}
$candidates = array(
array( plugins_url(), WP_PLUGIN_DIR ),
array( get_stylesheet_directory_uri(), get_stylesheet_directory() ),
array( get_template_directory_uri(), get_template_directory() ),
array( content_url(), WP_CONTENT_DIR ),
array( includes_url(), ABSPATH . WPINC ),
);
foreach ( $candidates as $pair ) {
list( $url_base, $path_base ) = $pair;
if ( ! $url_base || ! $path_base ) {
continue;
}
$url_base = rtrim( $url_base, '/' );
if ( 0 !== strpos( $clean, $url_base . '/' ) && $clean !== $url_base ) {
continue;
}
$relative = ltrim( substr( $clean, strlen( $url_base ) ), '/' );
$candidate = trailingslashit( $path_base ) . $relative;
$real_base = realpath( $path_base );
$real = realpath( $candidate );
if ( ! $real_base || ! $real ) {
return false;
}
// Guard against `..`-traversal: resolved path must stay inside
// the registered base.
if ( 0 !== strpos( $real, $real_base ) ) {
return false;
}
return $real;
}
return false;
}
private static function cache_path( $key, $type ) {
return self::min_dir() . '/' . $key . '.' . $type;
}
private static function ensure_dir( $dir ) {
if ( ! file_exists( $dir ) ) {
wp_mkdir_p( $dir );
Cache::write_silence( $dir );
}
}
public static function purge_minified() {
$dir = self::min_dir();
if ( ! is_dir( $dir ) ) {
return;
}
foreach ( glob( $dir . '/*' ) as $file ) {
wp_delete_file( $file );
}
}
}