# xspeed/1.1.1/includes/class-minifier.php

xSpeed Cache: AI-Powered Performance Hub with MCP, Caching &amp; CDN, version 1.1.1. 379 lines.

- Page: https://pluginprobe.com/plugins/xspeed/1.1.1/code/includes/class-minifier.php
- Raw: https://pluginprobe.com/plugins/xspeed/1.1.1/raw/includes/class-minifier.php
- Modified: 2026-07-16T11:41:06+00:00

Line numbers below start at 1. Link to a line or a range by appending a fragment to the
page URL, for example `https://pluginprobe.com/plugins/xspeed/1.1.1/code/includes/class-minifier.php#L10-L20`.

```php
<?php
/**
 * Asset minifier — HTML, CSS, JS.
 *
 * Uses matthiasmullie/minify for CSS/JS. Local enqueued assets are minified
 * once, cached on disk, and the loader URL is rewritten to point at the
 * cached file.
 *
 * @package XSpeed
 */

namespace XSpeed;

defined( 'ABSPATH' ) || exit;

class Minifier {

	const MIN_SUBDIR = 'min';

	/**
	 * Absolute path to the minified-cache directory. Always derived from
	 * XSPEED_CACHE_DIR (the plugin's own cache root) — never assembled from
	 * arbitrary URL fragments.
	 */
	public static function min_dir() {
		return trailingslashit( XSPEED_CACHE_DIR ) . self::MIN_SUBDIR;
	}

	/**
	 * Public URL of the minified-cache directory. Built from content_url() +
	 * the known relative path, not by string-replacing WP_CONTENT_DIR out of
	 * a filesystem path (which would assume the filesystem layout matches
	 * the URL layout — it does not on Bedrock-style installs, multisite with
	 * mapped domains, or any setup with a relocated wp-content).
	 */
	private static function min_url() {
		// XSPEED_CACHE_DIR lives under wp-content (defined in xspeed.php as
		// WP_CONTENT_DIR . '/cache/xspeed'), so the URL is content_url() +
		// the known suffix. We do not derive URLs from arbitrary filesystem
		// paths anywhere in this plugin.
		$url = trailingslashit( content_url( 'cache/xspeed' ) ) . self::MIN_SUBDIR;
		// Force the site's scheme: content_url() derives its scheme from
		// is_ssl(), which is false behind a TLS-terminating reverse proxy, so
		// it can emit an http:// URL on an https page — the browser then blocks
		// the minified stylesheet as mixed content and the page renders
		// unstyled. Match home_url()'s registered scheme instead. (FBS-83633)
		$scheme = wp_parse_url( home_url(), PHP_URL_SCHEME ) ?: 'https';
		return set_url_scheme( $url, $scheme );
	}

	public function __construct() {
		// Only run on the frontend — never minify wp-admin, AJAX, REST or cron
		// asset URLs. Page caching already handles the logged-in case for
		// the HTML response; minify scope is the public frontend.
		if ( is_admin() || ( defined( 'DOING_AJAX' ) && DOING_AJAX ) || ( defined( 'DOING_CRON' ) && DOING_CRON ) || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) {
			return;
		}

		// Settings now live in the per-module option (xspeed_module_minify),
		// owned by XSpeed\Modules\Minify\MinifyModule. We read through
		// Settings_Manager so schema-validated values are returned even
		// if the option was hand-edited.
		$opts = Settings_Manager::get( 'minify' );

		if ( ! empty( $opts['minify_css'] ) ) {
			add_filter( 'style_loader_src', array( __CLASS__, 'rewrite_style' ), 10, 2 );
		}
		if ( ! empty( $opts['minify_js'] ) ) {
			add_filter( 'script_loader_src', array( __CLASS__, 'rewrite_script' ), 10, 2 );
		}

		// Phase 4.1a — filter-only "smarter minifier" features. Each is
		// gated on its own toggle so users can enable any subset.
		if ( ! empty( $opts['remove_query_strings'] ) ) {
			add_filter( 'style_loader_src',  array( Minify_Filters::class, 'strip_version_query' ), 20 );
			add_filter( 'script_loader_src', array( Minify_Filters::class, 'strip_version_query' ), 20 );
		}
		if ( ! empty( $opts['defer_js'] ) ) {
			add_filter( 'script_loader_tag', array( Minify_Filters::class, 'defer_script_tag' ), 20, 3 );
		}
		if ( ! empty( $opts['delay_js'] ) ) {
			// Delay applies a transform that's mutually exclusive with
			// plain defer — when both are on, delay wins (the bootstrap
			// will re-attach as a regular <script> 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( '/<!--(?!\[if).*?-->/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;
		}

		// Skip anything we already produced. The Asset_Combiner writes a
		// pre-minified combined-<hash>.css under min/combined/ and enqueues it
		// as `xspeed-combined-css`; the per-file minifier used to re-minify
		// that combined output into a SECOND file (min/<hash2>.css) with its
		// own mtime-derived hash. The served HTML then pinned that second
		// hash, so a purge/regeneration (which changes the combined file's
		// mtime -> a new hash2) left the cached page pointing at a file that
		// no longer existed -> 404 -> unstyled/broken frontend. Leaving our
		// own cache output untouched keeps a single, stable URL end-to-end.
		if ( false !== strpos( $src, '/cache/xspeed/' ) ) {
			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 {
			if ( 'css' === $type ) {
				// Passing the TARGET path makes matthiasmullie/minify rebase every
				// relative url(...) / @import against the minified file's location.
				// Without it, a stylesheet moved from e.g.
				// .../font-awesome/css/all.css to cache/xspeed/min/<key>.css keeps
				// its original url(../webfonts/…) — which then resolves against the
				// cache dir and 404s (missing FontAwesome/eicons/WooCommerce fonts).
				$minifier = new \MatthiasMullie\Minify\CSS( $source_path );
				$minified = $minifier->minify( $target_path );
				return '' !== $minified && file_exists( $target_path );
			}

			$minifier = 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() {
		self::rmtree_files( self::min_dir() );
	}

	/**
	 * Recursively delete every file under $dir (and the emptied
	 * subdirectories), keeping $dir itself. The previous glob('$dir/*')
	 * was non-recursive and no-ops on directories, so combined assets in
	 * min/combined/ were never cleared — a purge left a stale
	 * combined-<hash>.css the regenerated page no longer referenced.
	 * (FBS-83114 / FBS-83116)
	 */
	private static function rmtree_files( string $dir ): void {
		if ( ! is_dir( $dir ) ) {
			return;
		}
		foreach ( (array) glob( $dir . '/*' ) as $path ) {
			if ( is_dir( $path ) ) {
				self::rmtree_files( $path );
				@rmdir( $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir, WordPress.PHP.NoSilencedErrors.Discouraged -- best-effort cleanup of our own cache subdir; WP_Filesystem is unavailable on the frontend purge path.
				continue;
			}
			wp_delete_file( $path );
		}
	}
}

```
